| 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. |
| 9 | import test from 'node:test' |
| 10 | import assert from 'node:assert/strict' |
| 11 | import * as Y from 'yjs' |
| 12 | import { prosemirrorToYXmlFragment, yXmlFragmentToProsemirrorJSON } from 'y-prosemirror' |
| 13 | import { schema, textFragment, writeText, readText, docFromParagraphs, safeHref } from './schema.mjs' |
| 14 | |
| 15 | // A paragraph with every mark the schema has, the link carrying bold. |
| 16 | const 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. */ |
| 30 | function 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 | |
| 38 | test('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 | |
| 61 | test('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 | |
| 75 | test('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 | |
| 87 | test('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 | |
| 99 | test('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 | |
| 107 | test('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 | |