oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// The §2.6 text schema across the y-prosemirror bridge. schema.mjs and
2// core/src/schema.rs write the marks as the formatting attributes b, i,
3// c, h (true) and href ({href: url}) precisely so that y-prosemirror's
4// own attribute mapping — attrs[mark.type.name] = mark.attrs one way,
5// schema.mark(attrName, attrValue) the other — is the identity for this
6// schema, with no mapping code in the editor. These tests are the proof:
7// what the core writes, ProseMirror reads as the same marks; what
8// ProseMirror writes, the core's reader reads as the same runs.
9import test from 'node:test'
10import assert from 'node:assert/strict'
11import * as Y from 'yjs'
12import { prosemirrorToYXmlFragment, yXmlFragmentToProsemirrorJSON } from 'y-prosemirror'
13import { schema, textFragment, writeText, readText, docFromParagraphs, safeHref } from './schema.mjs'
14
15// A paragraph with every mark the schema has, the link carrying bold.
16const paragraphs = [[
17 { t: 'plain ' },
18 { t: 'bold', b: true },
19 { t: ' ' },
20 { t: 'link', href: 'https://example.com/a?b=c' },
21 { t: ' ' },
22 { t: 'both', b: true, href: 'https://example.com/d' },
23 { t: ' ' },
24 { t: 'i', i: true },
25 { t: 'c', c: true },
26 { t: 'h', h: true }
27]]
28
29/** A Y.Doc holding `text`, written the way the core writes it. */
30function written (text) {
31 const doc = new Y.Doc({ guid: 'schema-test' })
32 doc.clientID = 1
33 const frag = doc.getXmlFragment('text')
34 writeText(frag, text)
35 return { doc, frag }
36}
37
38test('the core-written structure reads as ProseMirror marks', () => {
39 const { frag } = written(paragraphs)
40 const json = yXmlFragmentToProsemirrorJSON(frag)
41 assert.equal(json.content.length, 1)
42 // schema.mark(name, value) carries the attribute's value through as the
43 // mark's attrs, so b/i/c/h come back with the writer's `true` — an
44 // attr-less mark reads them as set either way. Only href's URL matters.
45 const mark = m => (m.type === 'href' ? ['href', m.attrs.href] : [m.type])
46 const got = json.content[0].content.map(n => [n.text, (n.marks || []).map(mark)])
47 assert.deepEqual(got, [
48 ['plain ', []],
49 ['bold', [['b']]],
50 [' ', []],
51 ['link', [['href', 'https://example.com/a?b=c']]],
52 [' ', []],
53 ['both', [['b'], ['href', 'https://example.com/d']]],
54 [' ', []],
55 ['i', [['i']]],
56 ['c', [['c']]],
57 ['h', [['h']]]
58 ])
59})
60
61test('a y-prosemirror round trip preserves bold and the link', () => {
62 // The core writes it; ProseMirror reads it; ProseMirror writes it back
63 // over a fresh fragment; the core reads the same runs.
64 const { frag } = written(paragraphs)
65 const node = schema.nodeFromJSON(yXmlFragmentToProsemirrorJSON(frag))
66
67 const doc = new Y.Doc({ guid: 'schema-test-2' })
68 doc.clientID = 2
69 const out = doc.getXmlFragment('text')
70 prosemirrorToYXmlFragment(node, out)
71
72 assert.deepEqual(readText(out), paragraphs)
73})
74
75test('y-prosemirror writes href as the one-key map and marks as {}', () => {
76 // The shape the core's writer must match, straight from the plugin.
77 const doc = new Y.Doc({ guid: 'schema-test-3' })
78 doc.clientID = 3
79 const out = doc.getXmlFragment('text')
80 prosemirrorToYXmlFragment(docFromParagraphs([[{ t: 'x', b: true, href: 'https://y' }]]), out)
81 // JSON round-tripped: the plugin builds null-prototype attribute objects.
82 const delta = JSON.parse(JSON.stringify(out.toArray()[0].toArray()[0].toDelta()))
83 assert.deepEqual(delta, [{ insert: 'x', attributes: { b: {}, href: { href: 'https://y' } } }])
84 assert.deepEqual(readText(out), [[{ t: 'x', b: true, href: 'https://y' }]])
85})
86
87test('a plain-string href, written before the map shape, still reads', () => {
88 const doc = new Y.Doc({ guid: 'schema-test-4' })
89 doc.clientID = 4
90 const frag = doc.getXmlFragment('text')
91 const el = new Y.XmlElement('paragraph')
92 const text = new Y.XmlText()
93 el.insert(0, [text])
94 frag.insert(0, [el])
95 text.applyDelta([{ insert: 'old', attributes: { href: 'https://old' } }])
96 assert.deepEqual(readText(frag), [[{ t: 'old', href: 'https://old' }]])
97})
98
99test('textFragment writes the same shape as writeText', () => {
100 const doc = new Y.Doc({ guid: 'schema-test-5' })
101 doc.clientID = 5
102 const frag = doc.getXmlFragment('text')
103 frag.insert(0, [textFragment(paragraphs)])
104 assert.deepEqual(readText(frag), paragraphs)
105})
106
107test('safeHref is the server allowlist, and a disallowed href never draws an anchor', () => {
108 assert.equal(safeHref('https://a.example/x?y=1'), 'https://a.example/x?y=1')
109 assert.equal(safeHref('HTTP://a'), 'HTTP://a')
110 assert.equal(safeHref('mailto:a@b'), 'mailto:a@b')
111 for (const bad of ['javascript:alert(1)', 'JAVASCRIPT:x', 'data:text/html,x', 'vbscript:x', '/path', 'a.example', ' https://a', 'https://a ', '', null, 7]) {
112 assert.equal(safeHref(bad), '', String(bad))
113 }
114 const mark = schema.marks.href
115 assert.deepEqual(mark.spec.toDOM(mark.create({ href: 'https://ok' })), ['a', { href: 'https://ok' }, 0])
116 assert.deepEqual(mark.spec.toDOM(mark.create({ href: 'javascript:alert(1)' })), ['span', { 'data-href': 'javascript:alert(1)' }, 0])
117 assert.deepEqual(mark.spec.toDOM(mark.create({ href: null })), ['span', { 'data-href': '' }, 0])
118})
119