oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Generates the ProseMirror-shaped Y.XmlFragment fixtures that define the
2// wire contract for core/src/fragment.rs. These are the contract: never
3// edit a fixture to make a test pass. A deliberate rule change regenerates
4// them in the same commit.
5import * as Y from 'yjs'
6import { writeFileSync, mkdirSync } from 'node:fs'
7
8// The client id is pinned so the bytes are reproducible. Options::
9// with_guid_and_client_id on the Rust side pins the same pair; nothing
10// in this pipeline may draw entropy (see the plan's Global Constraints).
11function docWith(build) {
12 const doc = new Y.Doc()
13 doc.clientID = 1
14 build(doc.getXmlFragment('prosemirror'))
15 return Y.encodeStateAsUpdate(doc)
16}
17
18function el(name, attrs, ...children) {
19 const e = new Y.XmlElement(name)
20 for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, v)
21 if (children.length) e.insert(0, children)
22 return e
23}
24
25function text(s, marks) {
26 const t = new Y.XmlText()
27 t.insert(0, s, marks ?? undefined)
28 return t
29}
30
31mkdirSync('vectors/fragment', { recursive: true })
32
33writeFileSync('vectors/fragment/simple.bin', docWith(f => {
34 f.insert(0, [
35 el('paragraph', null, text('First paragraph.')),
36 el('paragraph', null, text('Second paragraph.')),
37 ])
38}))
39
40writeFileSync('vectors/fragment/marks.bin', docWith(f => {
41 f.insert(0, [
42 el('paragraph', null,
43 text('Plain then '), text('bold', { strong: {} }),
44 text(' then '), text('italic', { em: {} }), text('.')),
45 ])
46}))
47
48writeFileSync('vectors/fragment/nested.bin', docWith(f => {
49 f.insert(0, [
50 el('heading', { level: '2' }, text('A heading')),
51 el('bullet_list', null,
52 el('list_item', null, el('paragraph', null, text('One'))),
53 el('list_item', null, el('paragraph', null, text('Two')))),
54 ])
55}))
56
57// styled.bin covers what the formatting bar can produce that the three
58// fixtures above cannot: the font and size marks (core/src/fragment.rs's
59// MARKS table), a run carrying two marks at once, and the two block
60// shapes — blockquote and ordered_list — that had no fixture before the
61// export paths needed to render them. The marks are attribute-free on
62// purpose; see that table's own comment for why a mark never carries a
63// value.
64writeFileSync('vectors/fragment/styled.bin', docWith(f => {
65 f.insert(0, [
66 el('paragraph', null,
67 text('sans', { font_sans: {} }),
68 text(' mono', { font_mono: {} }),
69 text(' large', { size_lg: {} }),
70 text(' both', { font_sans: {}, strong: {} })),
71 el('blockquote', null, el('paragraph', null, text('Quoted.'))),
72 el('ordered_list', null,
73 el('list_item', null, el('paragraph', null, text('First'))),
74 el('list_item', null, el('paragraph', null, text('Second')))),
75 ])
76}))
77
78// fonts.bin covers the font marks on their own, and exists because
79// serif became sayable. Until sans took the default over, "serif" was
80// spelled as the ABSENCE of a mark — there was no font_serif — so the
81// vocabulary styled.bin pins had no way to express the face most
82// documents were actually in. This fixture pins the whole of it: an
83// unmarked run, which carries no span whatever the default happens to
84// be, and one run per face.
85//
86// A fixture of its own, rather than three more runs inside styled.bin.
87// These vectors are the wire contract, not an output of it: styled.bin's
88// bytes are what a dozen assertions in core/tests/fragment.rs and
89// internal/docs already compare against, and regenerating it would
90// quietly change what every one of them means. Adding a vector changes
91// nothing that already exists.
92writeFileSync('vectors/fragment/fonts.bin', docWith(f => {
93 f.insert(0, [
94 el('paragraph', null,
95 text('Unmarked'),
96 text(' serif', { font_serif: {} }),
97 text(' sans', { font_sans: {} }),
98 text(' mono', { font_mono: {} })),
99 ])
100}))
101
102// aligned.bin covers the first block-level formatting: a paragraph's
103// `align` attribute. Unlike a mark, alignment belongs to the block, so
104// it is a real Yjs XML attribute on the element — which is exactly why
105// it needs a fixture of its own. y-prosemirror stores node attributes
106// this way (createTypeFromElementNode: setAttribute for every non-null
107// attr), so this is what an aligned document ACTUALLY contains on the
108// wire, and core/src/fragment.rs has to read it back from there rather
109// than from anyone's description of it.
110//
111// The first paragraph carries no attribute at all, which is the whole
112// point of left being the default: the editor's schema stores null,
113// y-prosemirror skips null, and an unaligned document is byte-identical
114// to one written before alignment existed. The last one says "left" out
115// loud, which a paste or a future build can produce and which must
116// render as no class rather than as a class of its own.
117writeFileSync('vectors/fragment/aligned.bin', docWith(f => {
118 f.insert(0, [
119 el('paragraph', null, text('Left by default.')),
120 el('paragraph', { align: 'center' }, text('Centred.')),
121 el('paragraph', { align: 'right' }, text('Right.')),
122 el('paragraph', { align: 'left' }, text('Left, said out loud.')),
123 ])
124}))
125
126// indented.bin covers the second block attribute, and it exists as its
127// own vector for a reason aligned.bin did not have: indent's value is a
128// NUMBER, where align's is a string from a closed set of three.
129//
130// That difference is the thing this fixture is here to pin. A string
131// attribute can only be handled one way — look it up in a table, drop
132// anything not in it — so aligned.bin's job was to prove the table is
133// consulted. A number has to be parsed and clamped instead, which is a
134// second mechanism with a second set of ways to be wrong, and the
135// wrong-looking cases are exactly the ones no editor will ever produce:
136// a level past the maximum, a zero, a value that is not a number at
137// all. POST /d/{sqid}/updates takes raw Yjs bytes, so every one of them
138// is reachable by a client that never went through the schema, and the
139// renderer's output goes into the page unescaped.
140//
141// So the last three paragraphs here are not decoration. "99" must
142// render clamped to the deepest level rather than as docs-indent-99;
143// "0" must render as no class at all, the same as no attribute;
144// "3; drop" must fail to parse and render flush left, never reaching
145// attribute position. A build that interpolated the attribute straight
146// into a class name would pass every test written against the first
147// three paragraphs and fail these.
148//
149// The first paragraph carries no attribute, for aligned.bin's own
150// reason: an un-indented document must be byte-identical to one written
151// before indent existed.
152//
153// The heading is here because headings are indentable and are not
154// alignable — a real asymmetry in the schema, and one that a fixture
155// covering only paragraphs would let a renderer get wrong in silence.
156writeFileSync('vectors/fragment/indented.bin', docWith(f => {
157 f.insert(0, [
158 el('paragraph', null, text('Flush left, no attribute.')),
159 el('paragraph', { indent: '1' }, text('One step.')),
160 el('paragraph', { indent: '8' }, text('The deepest step.')),
161 el('heading', { level: '2', indent: '2' }, text('An indented heading.')),
162 el('paragraph', { align: 'center', indent: '3' }, text('Centred and indented.')),
163 el('paragraph', { indent: '99' }, text('Past the maximum; clamps.')),
164 el('paragraph', { indent: '0' }, text('Zero is no indent.')),
165 el('paragraph', { indent: '3; drop' }, text('Not a number at all.')),
166 // The four values JavaScript's Number() accepts and a Rust parse
167 // does not. They are here because the two sides disagreed about
168 // every one of them for a review cycle — the editor rendered them
169 // indented and the read view rendered them flush left — and the
170 // agreement is now the thing being pinned rather than the clamp.
171 el('paragraph', { indent: '400' }, text('Too large for a u8; still clamps.')),
172 el('paragraph', { indent: ' 3' }, text('Leading space is not a number.')),
173 el('paragraph', { indent: '3.0' }, text('Nor is a decimal point.')),
174 el('paragraph', { indent: '0x3' }, text('Nor is hex.')),
175 ])
176}))
177
178// sizes.bin covers the point ladder, and the three legacy marks
179// alongside it.
180//
181// styled.bin already pins size_sm/lg/xl, so this could have been read as
182// redundant. It is not: the legacy marks are pinned there as the sizes
183// this editor WRITES, and here as the sizes it must go on UNDERSTANDING
184// after it stopped writing them. A change that dropped them from MARKS
185// would leave styled.bin's assertions to be "fixed" by regenerating it;
186// this fixture states the property directly, so the same change fails
187// with a message about documents in the wild.
188//
189// The mixed run at the end is the case neither renderer can produce and
190// POST /d/{sqid}/updates can: a run carrying a ladder mark AND a legacy
191// one. Both this build and internal/export/pdf.go resolve it the same
192// way — ladder first, in a fixed order — because a document must not
193// measure one size on screen and another on paper, and an arbitrary but
194// stable answer is what determinism needs.
195writeFileSync('vectors/fragment/sizes.bin', docWith(f => {
196 f.insert(0, [
197 el('paragraph', null,
198 text('Body'),
199 text(' eight', { size_8: {} }),
200 text(' fourteen', { size_14: {} }),
201 text(' seventy-two', { size_72: {} })),
202 el('paragraph', null,
203 text('Small', { size_sm: {} }),
204 text(' Large', { size_lg: {} }),
205 text(' Extra', { size_xl: {} })),
206 el('paragraph', null,
207 text('Both at once', { size_lg: {}, size_24: {} })),
208 ])
209}))
210
211// --- trap-containment vectors ---
212//
213// core/tests/wasm_trap_containment.rs mutation-fuzzes real fixtures and
214// checks a control document's content survives untouched, reading it
215// back through the wasm ABI's docs_core_text_len/docs_core_text_read.
216// Those now read fragment_text (root "prosemirror"), not text_content
217// (root "content") — see core/src/abi.rs's text_string doc comment —
218// so that test needs fragment-shaped fixtures of its own rather than
219// fixtures/data's Y.Text ones (which read back as "" through that ABI
220// now). These four mirror fixtures/data's hello/unicode/formatted/
221// concurrent in content and purpose (plain ASCII, surrogate pairs and
222// combining marks, inline formatting, a concurrent merge) so the same
223// coverage that fuzzing wants — plain text, non-ASCII byte lengths,
224// format-op decoding, CRDT merge structure — survives the move, just
225// wrapped in a single top-level <paragraph> each instead of a bare
226// Y.Text.
227//
228// plainTextOf mirrors core/src/fragment.rs's fragment_text: walk the
229// tree, concatenate each Y.XmlText's own rendering, and join top-level
230// block texts with a blank line.
231//
232// Uses each Y.XmlText's own toString(), not toDelta() — verified
233// against the real Rust output, not assumed: fragment_text calls
234// yrs's XmlTextRef::get_string(txn), and yrs's own get_string embeds
235// formatting as inline pseudo-tags exactly the way Y.XmlText.toString()
236// does in JS ("plain <strong>bold</strong> plain", not "plain bold
237// plain") — this is XmlText's own "plain text" convention in both
238// implementations, not the HTML fragment_html builds via its own MARKS
239// table. A first version of this generator used toDelta() (mark-free)
240// and it disagreed with the real Rust output on the very first run —
241// core/tests/wasm_trap_containment.rs caught it immediately, which is
242// the whole reason this fixture set exists.
243function plainTextOf(doc) {
244 const f = doc.getXmlFragment('prosemirror')
245 const isBlock = (name) =>
246 ['paragraph', 'heading', 'list_item', 'blockquote'].includes(name)
247 const blocks = []
248 let cur = ''
249 function walk(node) {
250 if (node instanceof Y.XmlText) {
251 cur += node.toString()
252 } else if (node instanceof Y.XmlElement) {
253 for (const child of node.toArray()) walk(child)
254 if (isBlock(node.nodeName) && cur.length > 0) {
255 blocks.push(cur)
256 cur = ''
257 }
258 }
259 }
260 for (const child of f.toArray()) walk(child)
261 if (cur.length > 0) blocks.push(cur)
262 return blocks.join('\n\n')
263}
264
265// emitTrap writes both halves a fixture needs: the update bytes, and
266// (unlike docWith's three vectors above, which core/tests/fragment.rs
267// asserts against with hand-written literals) an expected.json next to
268// it, the same shape fixtures/generate.mjs writes for fixtures/data —
269// wasm_trap_containment.rs reads that "text" field the same way
270// wire_compat.rs's fixture() does.
271function emitTrap(name, build) {
272 const doc = new Y.Doc()
273 doc.clientID = 1
274 build(doc)
275 const update = Y.encodeStateAsUpdate(doc)
276 writeFileSync(`vectors/fragment/${name}.update.bin`, Buffer.from(update))
277 writeFileSync(
278 `vectors/fragment/${name}.expected.json`,
279 JSON.stringify({ text: plainTextOf(doc) }, null, 2) + '\n',
280 )
281 console.log(`${name}: ${update.length} bytes`)
282}
283
284emitTrap('hello', (doc) => {
285 doc.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('hello world'))])
286})
287
288emitTrap('unicode', (doc) => {
289 // Surrogate pairs and combining marks: Yjs indexes UTF-16 code units and
290 // Rust indexes UTF-8 bytes, same reasoning as fixtures/generate.mjs's
291 // own 'unicode' fixture.
292 doc.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('héllo 🌍 café'))])
293})
294
295emitTrap('formatted', (doc) => {
296 // Inline formatting: exercises format-op decoding under mutation
297 // fuzzing, same reasoning as fixtures/generate.mjs's own 'formatted'
298 // fixture. plainTextOf strips marks via toDelta, so the expected text
299 // is just the concatenated runs regardless of the strong attribute.
300 doc.getXmlFragment('prosemirror').insert(0, [
301 el('paragraph', null, text('plain '), text('bold', { strong: {} }), text(' plain')),
302 ])
303})
304
305emitTrap('concurrent', (doc) => {
306 // Two docs each insert their own top-level paragraph before syncing,
307 // then merge — order-independent CRDT merge structure, same
308 // reasoning as fixtures/generate.mjs's own 'concurrent' fixture, at
309 // the block level rather than mid-run since Y.XmlFragment's children
310 // are blocks, not characters.
311 const a = new Y.Doc()
312 a.clientID = 1
313 const b = new Y.Doc()
314 b.clientID = 2
315 // doc.clientID (set to 3 here) overrides the emitTrap assignment
316 // above, same as generate.mjs's own concurrent fixture.
317 doc.clientID = 3
318 a.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('AAA'))])
319 b.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('BBB'))])
320 Y.applyUpdate(a, Y.encodeStateAsUpdate(b))
321 Y.applyUpdate(b, Y.encodeStateAsUpdate(a))
322 Y.applyUpdate(doc, Y.encodeStateAsUpdate(a))
323})
324
325console.log('wrote 6 fragment vectors + 4 trap-containment vectors')
326