oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
31import * as Y from 'yjs'
32import { 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. */
35export const MARK_ATTRIBUTES = ['b', 'i', 'c', 'h', 'href']
36
37const isFragment = v => v instanceof Y.XmlFragment && !(v instanceof Y.XmlElement)
38
39function 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 */
58export 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. */
86function 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. */
111export 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 */
126export 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