// The ยง2.6 text schema across the y-prosemirror bridge. schema.mjs and // core/src/schema.rs write the marks as the formatting attributes b, i, // c, h (true) and href ({href: url}) precisely so that y-prosemirror's // own attribute mapping โ€” attrs[mark.type.name] = mark.attrs one way, // schema.mark(attrName, attrValue) the other โ€” is the identity for this // schema, with no mapping code in the editor. These tests are the proof: // what the core writes, ProseMirror reads as the same marks; what // ProseMirror writes, the core's reader reads as the same runs. import test from 'node:test' import assert from 'node:assert/strict' import * as Y from 'yjs' import { prosemirrorToYXmlFragment, yXmlFragmentToProsemirrorJSON } from 'y-prosemirror' import { schema, textFragment, writeText, readText, docFromParagraphs, safeHref } from './schema.mjs' // A paragraph with every mark the schema has, the link carrying bold. const paragraphs = [[ { t: 'plain ' }, { t: 'bold', b: true }, { t: ' ' }, { t: 'link', href: 'https://example.com/a?b=c' }, { t: ' ' }, { t: 'both', b: true, href: 'https://example.com/d' }, { t: ' ' }, { t: 'i', i: true }, { t: 'c', c: true }, { t: 'h', h: true } ]] /** A Y.Doc holding `text`, written the way the core writes it. */ function written (text) { const doc = new Y.Doc({ guid: 'schema-test' }) doc.clientID = 1 const frag = doc.getXmlFragment('text') writeText(frag, text) return { doc, frag } } test('the core-written structure reads as ProseMirror marks', () => { const { frag } = written(paragraphs) const json = yXmlFragmentToProsemirrorJSON(frag) assert.equal(json.content.length, 1) // schema.mark(name, value) carries the attribute's value through as the // mark's attrs, so b/i/c/h come back with the writer's `true` โ€” an // attr-less mark reads them as set either way. Only href's URL matters. const mark = m => (m.type === 'href' ? ['href', m.attrs.href] : [m.type]) const got = json.content[0].content.map(n => [n.text, (n.marks || []).map(mark)]) assert.deepEqual(got, [ ['plain ', []], ['bold', [['b']]], [' ', []], ['link', [['href', 'https://example.com/a?b=c']]], [' ', []], ['both', [['b'], ['href', 'https://example.com/d']]], [' ', []], ['i', [['i']]], ['c', [['c']]], ['h', [['h']]] ]) }) test('a y-prosemirror round trip preserves bold and the link', () => { // The core writes it; ProseMirror reads it; ProseMirror writes it back // over a fresh fragment; the core reads the same runs. const { frag } = written(paragraphs) const node = schema.nodeFromJSON(yXmlFragmentToProsemirrorJSON(frag)) const doc = new Y.Doc({ guid: 'schema-test-2' }) doc.clientID = 2 const out = doc.getXmlFragment('text') prosemirrorToYXmlFragment(node, out) assert.deepEqual(readText(out), paragraphs) }) test('y-prosemirror writes href as the one-key map and marks as {}', () => { // The shape the core's writer must match, straight from the plugin. const doc = new Y.Doc({ guid: 'schema-test-3' }) doc.clientID = 3 const out = doc.getXmlFragment('text') prosemirrorToYXmlFragment(docFromParagraphs([[{ t: 'x', b: true, href: 'https://y' }]]), out) // JSON round-tripped: the plugin builds null-prototype attribute objects. const delta = JSON.parse(JSON.stringify(out.toArray()[0].toArray()[0].toDelta())) assert.deepEqual(delta, [{ insert: 'x', attributes: { b: {}, href: { href: 'https://y' } } }]) assert.deepEqual(readText(out), [[{ t: 'x', b: true, href: 'https://y' }]]) }) test('a plain-string href, written before the map shape, still reads', () => { const doc = new Y.Doc({ guid: 'schema-test-4' }) doc.clientID = 4 const frag = doc.getXmlFragment('text') const el = new Y.XmlElement('paragraph') const text = new Y.XmlText() el.insert(0, [text]) frag.insert(0, [el]) text.applyDelta([{ insert: 'old', attributes: { href: 'https://old' } }]) assert.deepEqual(readText(frag), [[{ t: 'old', href: 'https://old' }]]) }) test('textFragment writes the same shape as writeText', () => { const doc = new Y.Doc({ guid: 'schema-test-5' }) doc.clientID = 5 const frag = doc.getXmlFragment('text') frag.insert(0, [textFragment(paragraphs)]) assert.deepEqual(readText(frag), paragraphs) }) test('safeHref is the server allowlist, and a disallowed href never draws an anchor', () => { assert.equal(safeHref('https://a.example/x?y=1'), 'https://a.example/x?y=1') assert.equal(safeHref('HTTP://a'), 'HTTP://a') assert.equal(safeHref('mailto:a@b'), 'mailto:a@b') for (const bad of ['javascript:alert(1)', 'JAVASCRIPT:x', 'data:text/html,x', 'vbscript:x', '/path', 'a.example', ' https://a', 'https://a ', '', null, 7]) { assert.equal(safeHref(bad), '', String(bad)) } const mark = schema.marks.href assert.deepEqual(mark.spec.toDOM(mark.create({ href: 'https://ok' })), ['a', { href: 'https://ok' }, 0]) assert.deepEqual(mark.spec.toDOM(mark.create({ href: 'javascript:alert(1)' })), ['span', { 'data-href': 'javascript:alert(1)' }, 0]) assert.deepEqual(mark.spec.toDOM(mark.create({ href: null })), ['span', { 'data-href': '' }, 0]) })