| 1 | use docs_core::{fragment_html, fragment_json, fragment_text}; |
| 2 | use yrs::updates::decoder::Decode; |
| 3 | use yrs::{Doc, Options, Transact, Update, Xml, XmlFragment}; |
| 4 | |
| 5 | fn 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] |
| 15 | fn 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] |
| 21 | fn 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] |
| 30 | fn 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] |
| 38 | fn 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] |
| 50 | fn 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("<script>"), "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] |
| 69 | fn 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] |
| 79 | fn 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] |
| 97 | fn 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] |
| 115 | fn 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] |
| 136 | fn 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] |
| 155 | fn 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] |
| 182 | fn 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] |
| 204 | fn 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] |
| 220 | fn 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] |
| 241 | fn 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] |
| 257 | fn 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] |
| 266 | fn 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] |
| 292 | fn 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] |
| 315 | fn 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] |
| 336 | fn 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] |
| 351 | fn 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] |
| 385 | fn 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] |
| 412 | fn 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] |
| 439 | fn 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] |
| 467 | fn 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] |
| 517 | fn 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] |
| 567 | fn 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] |
| 620 | fn 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] |
| 661 | fn 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 | |