oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1use docs_core::{fragment_html, fragment_json, fragment_text};
2use yrs::updates::decoder::Decode;
3use yrs::{Doc, Options, Transact, Update, Xml, XmlFragment};
4
5fn doc_from(bytes: &[u8]) -> Doc {
6 let doc = Doc::with_options(Options::with_guid_and_client_id("fixture".into(), 1));
7 let update = Update::decode_v1(bytes).expect("decode fixture");
8 doc.transact_mut()
9 .apply_update(update)
10 .expect("apply fixture");
11 doc
12}
13
14#[test]
15fn reads_paragraphs_as_blocks() {
16 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
17 assert_eq!(fragment_text(&doc), "First paragraph.\n\nSecond paragraph.");
18}
19
20#[test]
21fn renders_paragraphs_as_html() {
22 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
23 assert_eq!(
24 fragment_html(&doc),
25 "<p>First paragraph.</p><p>Second paragraph.</p>"
26 );
27}
28
29#[test]
30fn renders_marks() {
31 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/marks.bin"));
32 let html = fragment_html(&doc);
33 assert!(html.contains("<strong>bold</strong>"), "got {html}");
34 assert!(html.contains("<em>italic</em>"), "got {html}");
35}
36
37#[test]
38fn renders_headings_and_lists() {
39 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin"));
40 let html = fragment_html(&doc);
41 assert_eq!(
42 html,
43 "<h2>A heading</h2><ul><li><p>One</p></li><li><p>Two</p></li></ul>"
44 );
45}
46
47// The read view puts this straight into a page. A document whose text
48// contains markup must not be able to inject it.
49#[test]
50fn escapes_html_in_text() {
51 let doc = Doc::with_options(Options::with_guid_and_client_id("esc".into(), 1));
52 {
53 let f = doc.get_or_insert_xml_fragment("prosemirror");
54 let mut txn = doc.transact_mut();
55 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
56 p.push_back(
57 &mut txn,
58 yrs::XmlTextPrelim::new("<script>alert(1)</script>"),
59 );
60 }
61 let html = fragment_html(&doc);
62 assert!(!html.contains("<script>"), "unescaped script tag: {html}");
63 assert!(html.contains("&lt;script&gt;"), "not escaped: {html}");
64}
65
66// An empty document is an empty canvas, not an error (show.html's own
67// comment says so).
68#[test]
69fn empty_document_is_empty_output() {
70 let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1));
71 assert_eq!(fragment_text(&doc), "");
72 assert_eq!(fragment_html(&doc), "");
73}
74
75// An unknown node name must not vanish silently and must not panic —
76// a future editor version can introduce nodes this build has never
77// seen, and dropping their text would be data loss in the read view.
78#[test]
79fn unknown_nodes_keep_their_text() {
80 let doc = Doc::with_options(Options::with_guid_and_client_id("unk".into(), 1));
81 {
82 let f = doc.get_or_insert_xml_fragment("prosemirror");
83 let mut txn = doc.transact_mut();
84 let n = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("call_to_action"));
85 n.push_back(&mut txn, yrs::XmlTextPrelim::new("keep me"));
86 }
87 assert!(fragment_text(&doc).contains("keep me"));
88 assert!(fragment_html(&doc).contains("keep me"));
89}
90
91// Task 1's parked finding (a): a text node siblinged directly ahead of
92// a block, with no block of its own — unreachable through
93// ProseMirror's own schema, but reachable the moment a client posts
94// arbitrary Yjs bytes (Task 5's POST /d/{sqid}/updates). Used to merge
95// into the following block's text with no separator ("orphanactual").
96#[test]
97fn text_sibling_before_a_block_is_separated() {
98 let doc = Doc::with_options(Options::with_guid_and_client_id("orphan".into(), 1));
99 {
100 let f = doc.get_or_insert_xml_fragment("prosemirror");
101 let mut txn = doc.transact_mut();
102 f.push_back(&mut txn, yrs::XmlTextPrelim::new("orphan"));
103 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
104 p.push_back(&mut txn, yrs::XmlTextPrelim::new("actual"));
105 }
106 assert_eq!(fragment_text(&doc), "orphan\n\nactual");
107}
108
109// Task 1's parked finding (b): fragment_html preserves an empty
110// `<p></p>` but fragment_text used to drop the block entirely — an
111// asymmetry no test covered until now. A structurally empty block
112// keeps a blank line in the text rendering, the same way it keeps an
113// empty tag pair in the HTML rendering.
114#[test]
115fn empty_block_is_preserved_like_html() {
116 let doc = Doc::with_options(Options::with_guid_and_client_id("emptyp".into(), 1));
117 {
118 let f = doc.get_or_insert_xml_fragment("prosemirror");
119 let mut txn = doc.transact_mut();
120 let p1 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
121 p1.push_back(&mut txn, yrs::XmlTextPrelim::new("First"));
122 f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
123 let p3 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
124 p3.push_back(&mut txn, yrs::XmlTextPrelim::new("Second"));
125 }
126 assert_eq!(fragment_html(&doc), "<p>First</p><p></p><p>Second</p>");
127 assert_eq!(fragment_text(&doc), "First\n\n\n\nSecond");
128}
129
130// A block nested inside another block (a paragraph inside a
131// list_item, ProseMirror's real shape for a list) must not gain a
132// spurious empty segment from the outer block once its inner text has
133// already been flushed — the regression the naive "always push on
134// is_block" fix for finding (b) would have introduced.
135#[test]
136fn nested_blocks_do_not_duplicate_their_text() {
137 let doc = Doc::with_options(Options::with_guid_and_client_id("nest".into(), 1));
138 {
139 let f = doc.get_or_insert_xml_fragment("prosemirror");
140 let mut txn = doc.transact_mut();
141 let li = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("list_item"));
142 let p = li.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
143 p.push_back(&mut txn, yrs::XmlTextPrelim::new("x"));
144 }
145 assert_eq!(fragment_text(&doc), "x");
146}
147
148// --- the font and size marks, and the structured renderer ---
149
150// The class names below are the contract between this renderer and
151// views/show.html's stylesheet, and between it and internal/pdf's
152// mark handling. They are constants in this binary, never bytes from a
153// document — see the MARKS table's own comment.
154#[test]
155fn renders_font_and_size_marks_as_classed_spans() {
156 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin"));
157 assert_eq!(
158 fragment_html(&doc),
159 concat!(
160 "<p>",
161 "<span class=\"docs-font-sans\">sans</span>",
162 "<span class=\"docs-font-mono\"> mono</span>",
163 "<span class=\"docs-size-lg\"> large</span>",
164 "<span class=\"docs-font-sans\"><strong> both</strong></span>",
165 "</p>",
166 "<blockquote><p>Quoted.</p></blockquote>",
167 "<ol><li><p>First</p></li><li><p>Second</p></li></ol>",
168 )
169 );
170}
171
172// Every face has a class, and the unmarked run has none.
173//
174// fonts.bin, not styled.bin: serif could not be said at all until
175// font_serif existed, so the older fixture has no run in it. The
176// unmarked run at the front is the half that is easy to leave untested
177// — whatever the default face is, a run nobody marked must come out as
178// bare text with no span at all, because that is what makes a document
179// written before any of this existed render as the default rather than
180// as whatever the default USED to be.
181#[test]
182fn renders_every_font_mark_as_a_classed_span() {
183 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/fonts.bin"));
184 assert_eq!(
185 fragment_html(&doc),
186 concat!(
187 "<p>",
188 "Unmarked",
189 "<span class=\"docs-font-serif\"> serif</span>",
190 "<span class=\"docs-font-sans\"> sans</span>",
191 "<span class=\"docs-font-mono\"> mono</span>",
192 "</p>",
193 )
194 );
195}
196
197// And the structured renderer names the same three marks. An unknown
198// mark reaches fragment_json anyway (mark_names carries it through in
199// the sorted tail, see its comment), so this test would pass on the
200// JSON alone while the read view above silently dropped the span —
201// which is exactly why the HTML assertion is the one above and not a
202// second look at the same output.
203#[test]
204fn json_names_every_font_mark() {
205 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/fonts.bin"));
206 assert_eq!(
207 fragment_json(&doc),
208 concat!(
209 "{\"type\":\"doc\",\"content\":[",
210 "{\"type\":\"paragraph\",\"content\":[",
211 "{\"type\":\"text\",\"text\":\"Unmarked\"},",
212 "{\"type\":\"text\",\"text\":\" serif\",\"marks\":[{\"type\":\"font_serif\"}]},",
213 "{\"type\":\"text\",\"text\":\" sans\",\"marks\":[{\"type\":\"font_sans\"}]},",
214 "{\"type\":\"text\",\"text\":\" mono\",\"marks\":[{\"type\":\"font_mono\"}]}]}]}",
215 )
216 );
217}
218
219#[test]
220fn renders_the_document_as_prosemirror_json() {
221 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin"));
222 assert_eq!(
223 fragment_json(&doc),
224 concat!(
225 "{\"type\":\"doc\",\"content\":[",
226 "{\"type\":\"heading\",\"attrs\":{\"level\":2},\"content\":[",
227 "{\"type\":\"text\",\"text\":\"A heading\"}]},",
228 "{\"type\":\"bullet_list\",\"content\":[",
229 "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
230 "{\"type\":\"text\",\"text\":\"One\"}]}]},",
231 "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
232 "{\"type\":\"text\",\"text\":\"Two\"}]}]}]}]}",
233 )
234 );
235}
236
237// Marks come out in MARKS order, not in whatever order the CRDT's own
238// attribute map happens to iterate — see mark_names. A run carrying two
239// of them is what actually exercises that.
240#[test]
241fn json_marks_are_in_a_fixed_order() {
242 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin"));
243 let json = fragment_json(&doc);
244 assert!(
245 json.contains(concat!(
246 "{\"type\":\"text\",\"text\":\" both\",\"marks\":[",
247 "{\"type\":\"font_sans\"},{\"type\":\"strong\"}]}",
248 )),
249 "got {json}"
250 );
251}
252
253// Same document, rendered twice in one process: identical bytes. A
254// HashMap-ordered mark list would pass this only by luck, which is why
255// the run above carries two marks.
256#[test]
257fn json_is_deterministic() {
258 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin"));
259 assert_eq!(fragment_json(&doc), fragment_json(&doc));
260}
261
262// The JSON goes into an HTTP response body and, from there, into a Go
263// json.Unmarshal. A document whose text contains quotes, backslashes or
264// control characters must not be able to break out of its string.
265#[test]
266fn escapes_json_in_text() {
267 let doc = Doc::with_options(Options::with_guid_and_client_id("jesc".into(), 1));
268 {
269 let f = doc.get_or_insert_xml_fragment("prosemirror");
270 let mut txn = doc.transact_mut();
271 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
272 p.push_back(
273 &mut txn,
274 yrs::XmlTextPrelim::new("a \"quote\" and a \\ and a \u{1}"),
275 );
276 }
277 assert_eq!(
278 fragment_json(&doc),
279 concat!(
280 "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
281 "{\"type\":\"text\",\"text\":\"a \\\"quote\\\" and a \\\\ and a \\u0001\"}]}]}",
282 )
283 );
284}
285
286// The module's rule — unknown nodes render their children rather than
287// disappearing — holds for the structured renderer too, with the node
288// itself reported under its own name so a consumer that grows to
289// understand it can. A newer editor build introducing a node this
290// binary has never heard of must not lose its text on export.
291#[test]
292fn json_keeps_unknown_nodes_and_their_children() {
293 let doc = Doc::with_options(Options::with_guid_and_client_id("junk".into(), 1));
294 {
295 let f = doc.get_or_insert_xml_fragment("prosemirror");
296 let mut txn = doc.transact_mut();
297 let outer = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("table"));
298 let p = outer.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
299 p.push_back(&mut txn, yrs::XmlTextPrelim::new("kept"));
300 }
301 assert_eq!(
302 fragment_json(&doc),
303 concat!(
304 "{\"type\":\"doc\",\"content\":[{\"type\":\"table\",\"content\":[",
305 "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"kept\"}]}]}]}",
306 )
307 );
308}
309
310// A heading level is clamped identically by both renderers. A document
311// that reads as an h6 on screen must not export as an h99 — the two
312// clamps are written separately, so this is the test that keeps them
313// equal.
314#[test]
315fn json_clamps_heading_level_like_html() {
316 let doc = Doc::with_options(Options::with_guid_and_client_id("hclamp".into(), 1));
317 {
318 let f = doc.get_or_insert_xml_fragment("prosemirror");
319 let mut txn = doc.transact_mut();
320 let h = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("heading"));
321 h.insert_attribute(&mut txn, "level", "99");
322 h.push_back(&mut txn, yrs::XmlTextPrelim::new("Shouty"));
323 }
324 assert!(fragment_html(&doc).starts_with("<h6>"), "html clamp");
325 assert!(
326 fragment_json(&doc).contains("\"attrs\":{\"level\":6}"),
327 "json clamp: {}",
328 fragment_json(&doc)
329 );
330}
331
332// An empty document is still a document: `{"type":"doc","content":[]}`,
333// never the empty string. internal/core's json read relies on that —
334// see abi.rs's json_string.
335#[test]
336fn json_of_an_empty_document_is_an_empty_doc_node() {
337 let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1));
338 assert_eq!(fragment_json(&doc), "{\"type\":\"doc\",\"content\":[]}");
339}
340
341// --- hard_break ---
342//
343// Shift+Enter's node. It is prosemirror-schema-basic's own
344// `hard_break`, and the name is not a choice this file gets to make:
345// y-prosemirror builds `Y.XmlElement(node.type.name)` for every
346// non-text node, so the tag in the CRDT is whatever the editor's
347// schema calls the node. All three renderers have to know it or a
348// break is silently dropped — the read view would run two lines
349// together as one, and both exporters would too.
350#[test]
351fn renders_a_hard_break_in_every_renderer() {
352 let doc = Doc::with_options(Options::with_guid_and_client_id("brk".into(), 1));
353 {
354 let f = doc.get_or_insert_xml_fragment("prosemirror");
355 let mut txn = doc.transact_mut();
356 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
357 p.push_back(&mut txn, yrs::XmlTextPrelim::new("first"));
358 p.push_back(&mut txn, yrs::XmlElementPrelim::empty("hard_break"));
359 p.push_back(&mut txn, yrs::XmlTextPrelim::new("second"));
360 }
361 // A void element: `<br>` opens and never closes, so nothing may be
362 // emitted after it.
363 assert_eq!(fragment_html(&doc), "<p>first<br>second</p>");
364 // One newline, not two: the break ends the line and the paragraph
365 // continues, where a paragraph boundary is a blank line.
366 assert_eq!(fragment_text(&doc), "first\nsecond");
367 // A leaf in the structured renderer, reported under its own name
368 // so the exporters can see it at all.
369 assert_eq!(
370 fragment_json(&doc),
371 concat!(
372 "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
373 "{\"type\":\"text\",\"text\":\"first\"},",
374 "{\"type\":\"hard_break\",\"content\":[]},",
375 "{\"type\":\"text\",\"text\":\"second\"}]}]}",
376 )
377 );
378}
379
380// A break is not a block: it ends a line inside its own paragraph and
381// nothing else. Two paragraphs either side of one must stay two
382// paragraphs — a blank line apart in the text rendering — rather than
383// the break's own newline being mistaken for a block boundary.
384#[test]
385fn a_hard_break_does_not_split_the_block_it_is_in() {
386 let doc = Doc::with_options(Options::with_guid_and_client_id("brk2".into(), 1));
387 {
388 let f = doc.get_or_insert_xml_fragment("prosemirror");
389 let mut txn = doc.transact_mut();
390 let p1 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
391 p1.push_back(&mut txn, yrs::XmlTextPrelim::new("one"));
392 p1.push_back(&mut txn, yrs::XmlElementPrelim::empty("hard_break"));
393 p1.push_back(&mut txn, yrs::XmlTextPrelim::new("two"));
394 let p2 = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
395 p2.push_back(&mut txn, yrs::XmlTextPrelim::new("three"));
396 }
397 assert_eq!(fragment_text(&doc), "one\ntwo\n\nthree");
398}
399
400// --- paragraph alignment ---
401//
402// The first block-level formatting. Unlike a mark it belongs to the
403// block and so lives in a real Yjs XML attribute, which is the whole
404// reason these tests are worth their length: MARKS can be a closed set
405// because a mark's presence IS its meaning, whereas an attribute
406// arrives carrying a value, and POST /d/{sqid}/updates accepts any Yjs
407// bytes a client cares to send. So the value never reaches the output
408// — it is only ever looked up in an allowlist whose right-hand side is
409// a literal in this binary.
410
411#[test]
412fn renders_paragraph_alignment_as_a_class() {
413 let doc = doc_from(include_bytes!(
414 "../../fixtures/vectors/fragment/aligned.bin"
415 ));
416 assert_eq!(
417 fragment_html(&doc),
418 concat!(
419 "<p>Left by default.</p>",
420 "<p class=\"docs-align-center\">Centred.</p>",
421 "<p class=\"docs-align-right\">Right.</p>",
422 // "left" said out loud is still the default, and the
423 // default spends no class — the same decision the MARKS
424 // table's own comment makes for serif and "normal", for the
425 // same reason: it would put an attribute on most of the
426 // blocks in most documents.
427 "<p>Left, said out loud.</p>",
428 )
429 );
430}
431
432// The exporters cannot see the CRDT, only this JSON — so alignment has
433// to be reported here or a centred paragraph exports flush left. It is
434// reported ONLY for the two alignments that are not the default, for
435// the same reason the HTML emits no class for left: an `attrs` object
436// on every paragraph would change the output for every document that
437// has never been aligned.
438#[test]
439fn json_reports_paragraph_alignment() {
440 let doc = doc_from(include_bytes!(
441 "../../fixtures/vectors/fragment/aligned.bin"
442 ));
443 assert_eq!(
444 fragment_json(&doc),
445 concat!(
446 "{\"type\":\"doc\",\"content\":[",
447 "{\"type\":\"paragraph\",\"content\":[",
448 "{\"type\":\"text\",\"text\":\"Left by default.\"}]},",
449 "{\"type\":\"paragraph\",\"attrs\":{\"align\":\"center\"},\"content\":[",
450 "{\"type\":\"text\",\"text\":\"Centred.\"}]},",
451 "{\"type\":\"paragraph\",\"attrs\":{\"align\":\"right\"},\"content\":[",
452 "{\"type\":\"text\",\"text\":\"Right.\"}]},",
453 "{\"type\":\"paragraph\",\"content\":[",
454 "{\"type\":\"text\",\"text\":\"Left, said out loud.\"}]}]}",
455 )
456 );
457}
458
459// The one that matters. fragment_html's output is written into the page
460// UNESCAPED (internal/docs/store.go's template.HTML), which is safe
461// only while no byte of a document reaches attribute position. An
462// alignment this build does not know — a typo, a future version's
463// `justify`, or the hand-crafted value below — is dropped whole. Never
464// escaped and emitted, never passed through, never interpolated: the
465// allowlist's right-hand side is the only thing that can appear.
466#[test]
467fn an_unknown_alignment_is_dropped_from_every_renderer() {
468 let hostile = "left\" onmouseover=\"alert(1)\" data-x=\"<script>";
469 let doc = Doc::with_options(Options::with_guid_and_client_id("align".into(), 1));
470 {
471 let f = doc.get_or_insert_xml_fragment("prosemirror");
472 let mut txn = doc.transact_mut();
473 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
474 p.insert_attribute(&mut txn, "align", hostile);
475 p.push_back(&mut txn, yrs::XmlTextPrelim::new("Words."));
476 let q = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
477 q.insert_attribute(&mut txn, "align", "justify");
478 q.push_back(&mut txn, yrs::XmlTextPrelim::new("Later."));
479 }
480
481 // The words survive. Dropping an alignment must never drop the
482 // text — the module comment's rule, and the whole reason unknown
483 // nodes render their children.
484 let html = fragment_html(&doc);
485 assert_eq!(html, "<p>Words.</p><p>Later.</p>");
486
487 // Said again as a search rather than as a comparison, because the
488 // comparison above would still pass if the value leaked into some
489 // other renderer, and because this is the assertion that actually
490 // names the defect: no fragment of the attacker's string may appear
491 // anywhere in the output.
492 for fragment in [hostile, "onmouseover", "alert(1)", "<script", "justify"] {
493 assert!(
494 !html.contains(fragment),
495 "{fragment:?} reached the read view's HTML: {html}"
496 );
497 }
498 let json = fragment_json(&doc);
499 for fragment in ["onmouseover", "alert(1)", "script", "justify", "attrs"] {
500 assert!(
501 !json.contains(fragment),
502 "{fragment:?} reached the structured renderer: {json}"
503 );
504 }
505 // And the plain-text rendering is untouched by any of it.
506 assert_eq!(fragment_text(&doc), "Words.\n\nLater.");
507}
508
509// Alignment is opt-in, and this is what says so in bytes rather than in
510// prose: fixtures written before alignment existed render exactly the
511// markup and exactly the JSON they rendered before, to the byte. The
512// failure it guards is the plausible one — emitting an `attrs` object
513// or a `class` on every paragraph — which changes nothing a reader can
514// see, changes every document's output, and which the aligned fixture
515// above would not notice at all.
516#[test]
517fn a_document_with_no_alignment_renders_exactly_as_it_did_before() {
518 let simple = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
519 assert_eq!(
520 fragment_html(&simple),
521 "<p>First paragraph.</p><p>Second paragraph.</p>"
522 );
523 assert_eq!(
524 fragment_json(&simple),
525 concat!(
526 "{\"type\":\"doc\",\"content\":[",
527 "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"First paragraph.\"}]},",
528 "{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Second paragraph.\"}]}]}",
529 )
530 );
531
532 let styled = doc_from(include_bytes!("../../fixtures/vectors/fragment/styled.bin"));
533 assert_eq!(
534 fragment_json(&styled),
535 concat!(
536 "{\"type\":\"doc\",\"content\":[",
537 "{\"type\":\"paragraph\",\"content\":[",
538 "{\"type\":\"text\",\"text\":\"sans\",\"marks\":[{\"type\":\"font_sans\"}]},",
539 "{\"type\":\"text\",\"text\":\" mono\",\"marks\":[{\"type\":\"font_mono\"}]},",
540 "{\"type\":\"text\",\"text\":\" large\",\"marks\":[{\"type\":\"size_lg\"}]},",
541 "{\"type\":\"text\",\"text\":\" both\",\"marks\":[{\"type\":\"font_sans\"},{\"type\":\"strong\"}]}]},",
542 "{\"type\":\"blockquote\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
543 "{\"type\":\"text\",\"text\":\"Quoted.\"}]}]},",
544 "{\"type\":\"ordered_list\",\"content\":[",
545 "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
546 "{\"type\":\"text\",\"text\":\"First\"}]}]},",
547 "{\"type\":\"list_item\",\"content\":[{\"type\":\"paragraph\",\"content\":[",
548 "{\"type\":\"text\",\"text\":\"Second\"}]}]}]}]}",
549 )
550 );
551}
552
553// Indent is the second block attribute, and the first whose value is a
554// NUMBER. Alignment's is a string, so the only safe handling is a table
555// lookup that emits the table's own right-hand side; a number is parsed
556// and clamped instead, which is a different mechanism with a different
557// set of ways to be wrong.
558//
559// The three paragraphs at the end are where that difference lives. No
560// editor produces them; POST /d/{sqid}/updates accepts raw Yjs bytes
561// from any client, so all three are reachable, and fragment_html's
562// output is written into the page unescaped. A build that interpolated
563// the attribute into a class name rather than a clamped integer would
564// satisfy every assertion about the first four paragraphs and fail
565// these.
566#[test]
567fn renders_block_indent_as_a_class() {
568 let doc = doc_from(include_bytes!(
569 "../../fixtures/vectors/fragment/indented.bin"
570 ));
571 assert_eq!(
572 fragment_html(&doc),
573 concat!(
574 // No attribute at all: byte-identical to a document written
575 // before indent existed, and spends no class.
576 "<p>Flush left, no attribute.</p>",
577 "<p class=\"docs-indent-1\">One step.</p>",
578 "<p class=\"docs-indent-8\">The deepest step.</p>",
579 // Headings indent and do not align — a real asymmetry in the
580 // schema, pinned here so a renderer cannot quietly drop one
581 // of the two.
582 "<h2 class=\"docs-indent-2\">An indented heading.</h2>",
583 // Both attributes on one paragraph, alignment first, exactly
584 // one space between them.
585 "<p class=\"docs-align-center docs-indent-3\">Centred and indented.</p>",
586 // Clamped, not emitted: never docs-indent-99.
587 "<p class=\"docs-indent-8\">Past the maximum; clamps.</p>",
588 // Zero is the same answer as no attribute.
589 "<p>Zero is no indent.</p>",
590 // Does not parse as a number, so it renders flush left. The
591 // value never reaches attribute position, which is the
592 // property the whole module exists to hold.
593 "<p>Not a number at all.</p>",
594 // The four JavaScript's Number() accepts and this once did
595 // not. "400" overflowed a u8 and rendered flush left while
596 // the editor rendered it at 8; the other three are values
597 // Number() coerces and a digits-only rule rejects. Both
598 // sides now apply the same two rules, so these pin the
599 // AGREEMENT rather than the clamp.
600 "<p class=\"docs-indent-8\">Too large for a u8; still clamps.</p>",
601 "<p>Leading space is not a number.</p>",
602 "<p>Nor is a decimal point.</p>",
603 "<p>Nor is hex.</p>",
604 )
605 );
606}
607
608// The exporters cannot see the CRDT, only this JSON, so indent has to
609// survive the crossing — and this is also where the attrs object itself
610// is pinned.
611//
612// That object used to be written by two independent arms, one for a
613// heading's level and one for a paragraph's alignment, each emitting a
614// complete "attrs":{…}. It was correct only while no node could carry
615// two attributes. An indented heading carries level AND indent, and an
616// indented centred paragraph carries align AND indent, so the old shape
617// would have emitted "attrs" twice in one object — which every JSON
618// parser accepts, none agrees on, and nothing would have reported.
619#[test]
620fn reports_block_indent_to_the_exporters() {
621 let doc = doc_from(include_bytes!(
622 "../../fixtures/vectors/fragment/indented.bin"
623 ));
624 let json = fragment_json(&doc);
625
626 // One attrs key per node, never two — the regression the single
627 // collected object exists to prevent.
628 for node in json.split("{\"type\":\"").skip(1) {
629 assert!(
630 node.matches("\"attrs\":").count() <= 1,
631 "a node emitted more than one attrs object: {node}"
632 );
633 }
634
635 assert!(json.contains("{\"type\":\"paragraph\",\"attrs\":{\"indent\":1},"));
636 assert!(json.contains("{\"type\":\"paragraph\",\"attrs\":{\"indent\":8},"));
637 // Level and indent together, level first.
638 assert!(json.contains("{\"type\":\"heading\",\"attrs\":{\"level\":2,\"indent\":2},"));
639 // Align and indent together, align first.
640 assert!(json.contains("{\"type\":\"paragraph\",\"attrs\":{\"align\":\"center\",\"indent\":3},"));
641 // Clamped, zero dropped, unparseable dropped — the same three cases
642 // the HTML test pins, checked again here because the two renderers
643 // read the attribute independently and a document must not indent
644 // one distance on screen and another on paper.
645 assert!(json.contains("\"text\":\"Past the maximum; clamps.\""));
646 assert!(json.contains("{\"type\":\"paragraph\",\"attrs\":{\"indent\":8},\"content\":[{\"type\":\"text\",\"text\":\"Past the maximum; clamps.\"}]}"));
647 assert!(json.contains("{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Zero is no indent.\"}]}"));
648 assert!(json.contains("{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Not a number at all.\"}]}"));
649}
650
651// The point ladder, and the four-step scale it replaced.
652//
653// Both halves matter and they matter for different reasons. The ladder
654// is what the editor writes today; a mark missing from MARKS renders as
655// nothing for a reader with no JavaScript, which is this module's own
656// "silence here is data loss". The legacy marks are what the editor
657// STOPPED writing, and they have to go on being understood forever —
658// documents in the wild carry them, and dropping one would silently
659// strip the size out of every document using it.
660#[test]
661fn renders_the_point_ladder_and_the_scale_it_replaced() {
662 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/sizes.bin"));
663 let html = fragment_html(&doc);
664
665 // 12pt is the default and carries no mark, so "Body" is bare text
666 // with no span at all — the same rule that keeps the default face
667 // and "normal" out of MARKS.
668 assert!(
669 html.contains("<p>Body"),
670 "the default size spent a span: {html}"
671 );
672
673 assert!(html.contains("<span class=\"docs-size-8\"> eight</span>"));
674 assert!(html.contains("<span class=\"docs-size-14\"> fourteen</span>"));
675 assert!(html.contains("<span class=\"docs-size-72\"> seventy-two</span>"));
676
677 assert!(html.contains("<span class=\"docs-size-sm\">Small</span>"));
678 assert!(html.contains("<span class=\"docs-size-lg\"> Large</span>"));
679 assert!(html.contains("<span class=\"docs-size-xl\"> Extra</span>"));
680
681 // A run carrying a ladder mark and a legacy one at once: impossible
682 // through the editor's schema, reachable through
683 // POST /d/{sqid}/updates. It renders both spans nested in MARKS
684 // order rather than dropping either — the module never suppresses
685 // text — and internal/export/pdf.go resolves the same run to one
686 // size by consulting sizeOrder, ladder first. Neither is wrong; they
687 // answer different questions, and both are stable.
688 // The nesting, not merely the text. This asserted only that the
689 // words survived, while its own comment claimed to pin the order —
690 // an assertion that could not fail against the thing it named.
691 // MARKS lists the ladder before the legacy names, and spans are
692 // pushed in table order and closed reversed, so size_24 is the
693 // outer span and size_lg the inner one.
694 assert!(
695 html.contains(
696 "<span class=\"docs-size-24\"><span class=\"docs-size-lg\">Both at once</span></span>"
697 ),
698 "the two size spans did not nest in MARKS order: {html}"
699 );
700}
701