| 1 | // fixtures/check.mjs — the accepted-shape check on a document's text |
| 2 | // (spec §2.6, codex 3): core/src/check.rs, walk for walk and message |
| 3 | // for message. |
| 4 | // |
| 5 | // The view (view.mjs) is tolerant on purpose: an unknown formatting |
| 6 | // attribute is dropped from a run, an element inside a paragraph |
| 7 | // contributes its string. That is right for reading a document that |
| 8 | // already exists and wrong for deciding whether to keep a write, because |
| 9 | // the editor does not consume the view — it binds y-prosemirror to the |
| 10 | // shared text itself, and y-prosemirror 1.3.7 deletes an XmlText whose |
| 11 | // attributes name a mark the schema does not have (sync-plugin.js, |
| 12 | // createTextNodesFromYText's catch: schema.mark throws a RangeError for |
| 13 | // an unknown name, and the catch deletes the item). So a document that |
| 14 | // views fine can lose its words the moment the editor opens. |
| 15 | // |
| 16 | // checkDoc walks every text fragment — each slide's notes, each block's |
| 17 | // text, each table's cells — and returns the first thing outside the |
| 18 | // vocabulary as a message, or null. The server runs the Rust twin on |
| 19 | // every posted update before storing it; this side runs it over the |
| 20 | // vectors (generate.mjs) so the fixtures themselves are proved clean, |
| 21 | // and the tests hold the two implementations to one answer. |
| 22 | // |
| 23 | // sanitiseFragment is the browser's own defence for a document that |
| 24 | // already carries such an attribute (stored before the gate existed, or |
| 25 | // merged in from a peer): it strips the attributes the schema does not |
| 26 | // know from the shared text BEFORE y-prosemirror binds to it. It has to |
| 27 | // be a write to the document rather than a filter on the way in, because |
| 28 | // y-prosemirror reads the Y type directly and offers no hook between the |
| 29 | // delta and schema.mark; losing an unknown mark is the smallest loss on |
| 30 | // offer, and the alternative the library takes is losing the words. |
| 31 | import * as Y from 'yjs' |
| 32 | import { roots, getString, getArray, getFragment, orderedMaps } from './ydoc.mjs' |
| 33 | |
| 34 | /** The formatting attributes §2.6 defines. Anything else on a run is outside the vocabulary. */ |
| 35 | export const MARK_ATTRIBUTES = ['b', 'i', 'c', 'h', 'href'] |
| 36 | |
| 37 | const isFragment = v => v instanceof Y.XmlFragment && !(v instanceof Y.XmlElement) |
| 38 | |
| 39 | function checkText (text, what, paragraph) { |
| 40 | for (const chunk of text.toDelta()) { |
| 41 | if (typeof chunk.insert !== 'string') { |
| 42 | return `${what}: paragraph ${paragraph} holds an embed, and the text schema holds only text` |
| 43 | } |
| 44 | for (const key of Object.keys(chunk.attributes || {}).sort()) { |
| 45 | if (!MARK_ATTRIBUTES.includes(key)) { |
| 46 | return `${what}: paragraph ${paragraph} carries the formatting attribute ${JSON.stringify(key)}, which is not one of ${MARK_ATTRIBUTES.join(', ')}` |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | return null |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Checks one text fragment; `what` names it for the message (slide "x" |
| 55 | * notes, slide "x" block "y" text, slide "x" block "y" cell 3). Returns |
| 56 | * the first problem as a string, or null. |
| 57 | */ |
| 58 | export function checkFragment (fragment, what) { |
| 59 | const children = fragment.toArray() |
| 60 | for (let i = 0; i < children.length; i++) { |
| 61 | const child = children[i] |
| 62 | if (child instanceof Y.XmlElement) { |
| 63 | if (child.nodeName !== 'paragraph') { |
| 64 | return `${what}: child ${i} is a <${child.nodeName}> element, and only <paragraph> is allowed` |
| 65 | } |
| 66 | for (const inner of child.toArray()) { |
| 67 | if (inner instanceof Y.XmlText) { |
| 68 | const problem = checkText(inner, what, i) |
| 69 | if (problem) return problem |
| 70 | } else if (inner instanceof Y.XmlElement) { |
| 71 | return `${what}: paragraph ${i} holds a <${inner.nodeName}> element, and a paragraph holds only text` |
| 72 | } else { |
| 73 | return `${what}: paragraph ${i} holds a nested fragment, and a paragraph holds only text` |
| 74 | } |
| 75 | } |
| 76 | } else if (child instanceof Y.XmlText) { |
| 77 | return `${what}: child ${i} is text outside a paragraph, and only <paragraph> is allowed` |
| 78 | } else { |
| 79 | return `${what}: child ${i} is a nested fragment, and only <paragraph> is allowed` |
| 80 | } |
| 81 | } |
| 82 | return null |
| 83 | } |
| 84 | |
| 85 | /** Every text fragment in the document: [fragment, what] pairs, in the order the core walks them. */ |
| 86 | function fragments (ydoc) { |
| 87 | const out = [] |
| 88 | const r = roots(ydoc) |
| 89 | for (const [, slide] of orderedMaps(r.slides)) { |
| 90 | const sid = JSON.stringify(getString(slide, 'id') ?? '') |
| 91 | const notes = getFragment(slide, 'notes') |
| 92 | if (notes) out.push([notes, `slide ${sid} notes`]) |
| 93 | const blocks = getArray(slide, 'blocks') |
| 94 | if (!blocks) continue |
| 95 | for (const [, block] of orderedMaps(blocks)) { |
| 96 | const bid = JSON.stringify(getString(block, 'id') ?? '') |
| 97 | const text = getFragment(block, 'text') |
| 98 | if (text) out.push([text, `slide ${sid} block ${bid} text`]) |
| 99 | const cells = getArray(block, 'cells') |
| 100 | if (cells) { |
| 101 | cells.toArray().forEach((cell, i) => { |
| 102 | if (isFragment(cell)) out.push([cell, `slide ${sid} block ${bid} cell ${i}`]) |
| 103 | }) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | return out |
| 108 | } |
| 109 | |
| 110 | /** The first thing in the document outside the §2.6 vocabulary, as the core's message, or null. */ |
| 111 | export function checkDoc (ydoc) { |
| 112 | for (const [fragment, what] of fragments(ydoc)) { |
| 113 | const problem = checkFragment(fragment, what) |
| 114 | if (problem) return problem |
| 115 | } |
| 116 | return null |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Strips every formatting attribute the schema does not know from the |
| 121 | * texts in `fragment`, so y-prosemirror can bind to it without deleting |
| 122 | * anything. A write to the document (in one transaction under `origin`) |
| 123 | * only when there is something to strip; returns how many runs were |
| 124 | * touched. The words are never changed. |
| 125 | */ |
| 126 | export function sanitiseFragment (fragment, origin = 'sanitise') { |
| 127 | const jobs = [] |
| 128 | for (const child of fragment.toArray()) { |
| 129 | if (!(child instanceof Y.XmlElement)) continue |
| 130 | for (const text of child.toArray()) { |
| 131 | if (!(text instanceof Y.XmlText)) continue |
| 132 | let pos = 0 |
| 133 | for (const chunk of text.toDelta()) { |
| 134 | const len = typeof chunk.insert === 'string' ? chunk.insert.length : 1 |
| 135 | const unknown = Object.keys(chunk.attributes || {}).filter(k => !MARK_ATTRIBUTES.includes(k)) |
| 136 | if (unknown.length) jobs.push([text, pos, len, Object.fromEntries(unknown.map(k => [k, null]))]) |
| 137 | pos += len |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | if (jobs.length === 0) return 0 |
| 142 | const run = () => { for (const [text, pos, len, attrs] of jobs) text.format(pos, len, attrs) } |
| 143 | if (fragment.doc) fragment.doc.transact(run, origin) |
| 144 | else run() |
| 145 | return jobs.length |
| 146 | } |
| 147 | |