// fixtures/check.mjs — the accepted-shape check on a document's text // (spec §2.6, codex 3): core/src/check.rs, walk for walk and message // for message. // // The view (view.mjs) is tolerant on purpose: an unknown formatting // attribute is dropped from a run, an element inside a paragraph // contributes its string. That is right for reading a document that // already exists and wrong for deciding whether to keep a write, because // the editor does not consume the view — it binds y-prosemirror to the // shared text itself, and y-prosemirror 1.3.7 deletes an XmlText whose // attributes name a mark the schema does not have (sync-plugin.js, // createTextNodesFromYText's catch: schema.mark throws a RangeError for // an unknown name, and the catch deletes the item). So a document that // views fine can lose its words the moment the editor opens. // // checkDoc walks every text fragment — each slide's notes, each block's // text, each table's cells — and returns the first thing outside the // vocabulary as a message, or null. The server runs the Rust twin on // every posted update before storing it; this side runs it over the // vectors (generate.mjs) so the fixtures themselves are proved clean, // and the tests hold the two implementations to one answer. // // sanitiseFragment is the browser's own defence for a document that // already carries such an attribute (stored before the gate existed, or // merged in from a peer): it strips the attributes the schema does not // know from the shared text BEFORE y-prosemirror binds to it. It has to // be a write to the document rather than a filter on the way in, because // y-prosemirror reads the Y type directly and offers no hook between the // delta and schema.mark; losing an unknown mark is the smallest loss on // offer, and the alternative the library takes is losing the words. import * as Y from 'yjs' import { roots, getString, getArray, getFragment, orderedMaps } from './ydoc.mjs' /** The formatting attributes §2.6 defines. Anything else on a run is outside the vocabulary. */ export const MARK_ATTRIBUTES = ['b', 'i', 'c', 'h', 'href'] const isFragment = v => v instanceof Y.XmlFragment && !(v instanceof Y.XmlElement) function checkText (text, what, paragraph) { for (const chunk of text.toDelta()) { if (typeof chunk.insert !== 'string') { return `${what}: paragraph ${paragraph} holds an embed, and the text schema holds only text` } for (const key of Object.keys(chunk.attributes || {}).sort()) { if (!MARK_ATTRIBUTES.includes(key)) { return `${what}: paragraph ${paragraph} carries the formatting attribute ${JSON.stringify(key)}, which is not one of ${MARK_ATTRIBUTES.join(', ')}` } } } return null } /** * Checks one text fragment; `what` names it for the message (slide "x" * notes, slide "x" block "y" text, slide "x" block "y" cell 3). Returns * the first problem as a string, or null. */ export function checkFragment (fragment, what) { const children = fragment.toArray() for (let i = 0; i < children.length; i++) { const child = children[i] if (child instanceof Y.XmlElement) { if (child.nodeName !== 'paragraph') { return `${what}: child ${i} is a <${child.nodeName}> element, and only is allowed` } for (const inner of child.toArray()) { if (inner instanceof Y.XmlText) { const problem = checkText(inner, what, i) if (problem) return problem } else if (inner instanceof Y.XmlElement) { return `${what}: paragraph ${i} holds a <${inner.nodeName}> element, and a paragraph holds only text` } else { return `${what}: paragraph ${i} holds a nested fragment, and a paragraph holds only text` } } } else if (child instanceof Y.XmlText) { return `${what}: child ${i} is text outside a paragraph, and only is allowed` } else { return `${what}: child ${i} is a nested fragment, and only is allowed` } } return null } /** Every text fragment in the document: [fragment, what] pairs, in the order the core walks them. */ function fragments (ydoc) { const out = [] const r = roots(ydoc) for (const [, slide] of orderedMaps(r.slides)) { const sid = JSON.stringify(getString(slide, 'id') ?? '') const notes = getFragment(slide, 'notes') if (notes) out.push([notes, `slide ${sid} notes`]) const blocks = getArray(slide, 'blocks') if (!blocks) continue for (const [, block] of orderedMaps(blocks)) { const bid = JSON.stringify(getString(block, 'id') ?? '') const text = getFragment(block, 'text') if (text) out.push([text, `slide ${sid} block ${bid} text`]) const cells = getArray(block, 'cells') if (cells) { cells.toArray().forEach((cell, i) => { if (isFragment(cell)) out.push([cell, `slide ${sid} block ${bid} cell ${i}`]) }) } } } return out } /** The first thing in the document outside the §2.6 vocabulary, as the core's message, or null. */ export function checkDoc (ydoc) { for (const [fragment, what] of fragments(ydoc)) { const problem = checkFragment(fragment, what) if (problem) return problem } return null } /** * Strips every formatting attribute the schema does not know from the * texts in `fragment`, so y-prosemirror can bind to it without deleting * anything. A write to the document (in one transaction under `origin`) * only when there is something to strip; returns how many runs were * touched. The words are never changed. */ export function sanitiseFragment (fragment, origin = 'sanitise') { const jobs = [] for (const child of fragment.toArray()) { if (!(child instanceof Y.XmlElement)) continue for (const text of child.toArray()) { if (!(text instanceof Y.XmlText)) continue let pos = 0 for (const chunk of text.toDelta()) { const len = typeof chunk.insert === 'string' ? chunk.insert.length : 1 const unknown = Object.keys(chunk.attributes || {}).filter(k => !MARK_ATTRIBUTES.includes(k)) if (unknown.length) jobs.push([text, pos, len, Object.fromEntries(unknown.map(k => [k, null]))]) pos += len } } } if (jobs.length === 0) return 0 const run = () => { for (const [text, pos, len, attrs] of jobs) text.format(pos, len, attrs) } if (fragment.doc) fragment.doc.transact(run, origin) else run() return jobs.length }