| 1 | // check.mjs: the accepted-shape check over a document's text, and the |
| 2 | // sanitiser the stage runs before binding (codex 3). The messages are |
| 3 | // core/src/check.rs's, so a case here has a twin in its tests. |
| 4 | import test from 'node:test' |
| 5 | import assert from 'node:assert/strict' |
| 6 | import * as Y from 'yjs' |
| 7 | import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror' |
| 8 | import { applyOps, view } from './ops.mjs' |
| 9 | import { schema, readText } from './schema.mjs' |
| 10 | import { checkDoc, checkFragment, sanitiseFragment, MARK_ATTRIBUTES } from './check.mjs' |
| 11 | import { blockFragment } from './stage.mjs' |
| 12 | import { poisonDoc } from './poison.mjs' |
| 13 | |
| 14 | function deck () { |
| 15 | const doc = new Y.Doc({ guid: 'check-test' }) |
| 16 | doc.clientID = 1 |
| 17 | applyOps(doc, [ |
| 18 | { op: 'add_slide', id: 'sld', layout: 'flow', template: 'title-body' }, |
| 19 | { op: 'set_notes', id: 'sld', markdown: 'Say **hello**' }, |
| 20 | { op: 'add_block', slide: 'sld', id: 'blk', type: 'text', markdown: '**b** *i* `c` ==h== [l](https://x)' }, |
| 21 | { op: 'add_block', slide: 'sld', id: 'tbl', type: 'table', props: { rows: 1, cols: 2 } } |
| 22 | ]) |
| 23 | return doc |
| 24 | } |
| 25 | |
| 26 | /** The one XmlText inside the block's first paragraph. */ |
| 27 | function firstText (fragment) { |
| 28 | return fragment.get(0).get(0) |
| 29 | } |
| 30 | |
| 31 | test('what the ops write passes, and so does an empty document', () => { |
| 32 | assert.equal(checkDoc(deck()), null) |
| 33 | assert.equal(checkDoc(new Y.Doc({ guid: 'empty' })), null) |
| 34 | }) |
| 35 | |
| 36 | test('the shapes y-prosemirror and older documents write pass', () => { |
| 37 | const doc = deck() |
| 38 | const t = firstText(blockFragment(doc, 'sld', 'blk')) |
| 39 | t.applyDelta([{ insert: 'x', attributes: { b: {}, href: 'https://old' } }]) |
| 40 | assert.equal(checkDoc(doc), null) |
| 41 | }) |
| 42 | |
| 43 | test('an unknown formatting attribute is reported with the core\'s message', () => { |
| 44 | const doc = deck() |
| 45 | const t = firstText(blockFragment(doc, 'sld', 'blk')) |
| 46 | t.applyDelta([{ insert: 'lost', attributes: { unknown_mark: true } }]) |
| 47 | // The projection still reads the words: that is exactly why the gate |
| 48 | // cannot be the projection. |
| 49 | assert.equal(view(doc).slides[0].blocks[0].text[0][0].t, 'lost') |
| 50 | assert.equal(checkDoc(doc), |
| 51 | 'slide "sld" block "blk" text: paragraph 0 carries the formatting attribute "unknown_mark", which is not one of b, i, c, h, href') |
| 52 | }) |
| 53 | |
| 54 | test('an element inside a paragraph, a non-paragraph child and bare text are reported', () => { |
| 55 | const doc = deck() |
| 56 | const slide = doc.getArray('slides').get(0) |
| 57 | slide.get('notes').get(0).insert(1, [new Y.XmlElement('strong')]) |
| 58 | assert.equal(checkDoc(doc), 'slide "sld" notes: paragraph 0 holds a <strong> element, and a paragraph holds only text') |
| 59 | |
| 60 | const doc2 = deck() |
| 61 | const tbl = doc2.getArray('slides').get(0).get('blocks').get(1) |
| 62 | tbl.get('cells').get(1).insert(0, [new Y.XmlElement('heading')]) |
| 63 | assert.equal(checkDoc(doc2), 'slide "sld" block "tbl" cell 1: child 0 is a <heading> element, and only <paragraph> is allowed') |
| 64 | |
| 65 | const doc3 = deck() |
| 66 | doc3.getArray('slides').get(0).get('notes').insert(1, [new Y.XmlText('loose')]) |
| 67 | assert.equal(checkDoc(doc3), 'slide "sld" notes: child 1 is text outside a paragraph, and only <paragraph> is allowed') |
| 68 | |
| 69 | const frag = new Y.Doc({ guid: 'f' }).getXmlFragment('t') |
| 70 | frag.insert(0, [new Y.XmlFragment()]) |
| 71 | assert.equal(checkFragment(frag, 'x'), 'x: child 0 is a nested fragment, and only <paragraph> is allowed') |
| 72 | }) |
| 73 | |
| 74 | test('the unknownmark poison shape is what the check refuses', () => { |
| 75 | const doc = poisonDoc('unknownmark') |
| 76 | assert.match(checkDoc(doc), /^slide "poisonmrksld" block "poisonmrkblk" text: paragraph 0 carries the formatting attribute "unknown_mark"/) |
| 77 | assert.deepEqual(view(doc).slides[0].blocks[0].text, [[{ t: 'keep these words' }]]) |
| 78 | }) |
| 79 | |
| 80 | test('y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them', () => { |
| 81 | // Codex 3 reproduced: the library's own conversion, no DOM needed. |
| 82 | const lost = deck() |
| 83 | const lostFrag = blockFragment(lost, 'sld', 'blk') |
| 84 | firstText(lostFrag).applyDelta([{ insert: 'keep ' }, { insert: 'these', attributes: { unknown_mark: true } }]) |
| 85 | assert.equal(readText(lostFrag)[0].map(r => r.t).join(''), 'keep theseb i c h l') |
| 86 | const node = yXmlFragmentToProseMirrorRootNode(lostFrag, schema) |
| 87 | assert.equal(node.textContent, '', 'the library emptied the paragraph') |
| 88 | assert.deepEqual(readText(lostFrag), [[]], 'and deleted the XmlText from the document') |
| 89 | |
| 90 | // The same document, sanitised the way bindView does before binding. |
| 91 | const kept = deck() |
| 92 | const keptFrag = blockFragment(kept, 'sld', 'blk') |
| 93 | firstText(keptFrag).applyDelta([{ insert: 'keep ' }, { insert: 'these', attributes: { unknown_mark: true, i: true } }]) |
| 94 | const origins = [] |
| 95 | kept.on('afterTransaction', tr => origins.push(tr.origin)) |
| 96 | assert.equal(sanitiseFragment(keptFrag), 1) |
| 97 | assert.deepEqual(origins, ['sanitise'], 'one transaction, under an origin the undo manager does not track') |
| 98 | assert.equal(checkDoc(kept), null) |
| 99 | const bound = yXmlFragmentToProseMirrorRootNode(keptFrag, schema) |
| 100 | assert.equal(bound.textContent, 'keep theseb i c h l', 'every word survives the bind') |
| 101 | // The known mark on the same run is untouched; only the unknown one went. |
| 102 | assert.deepEqual(readText(keptFrag)[0].slice(0, 2), [{ t: 'keep ' }, { t: 'these', i: true }]) |
| 103 | // Nothing to strip is not a write. |
| 104 | assert.equal(sanitiseFragment(keptFrag), 0) |
| 105 | assert.deepEqual(origins, ['sanitise']) |
| 106 | }) |
| 107 | |
| 108 | test('the vocabulary is the five marks and nothing else', () => { |
| 109 | assert.deepEqual(MARK_ATTRIBUTES, ['b', 'i', 'c', 'h', 'href']) |
| 110 | }) |
| 111 | |