| 1 | //! Rendering a ProseMirror-shaped Y.XmlFragment to text and HTML. |
| 2 | //! |
| 3 | //! This is the only place on the Rust side that knows ProseMirror's |
| 4 | //! node vocabulary. It exists because the read view is server-rendered |
| 5 | //! (spec §6.2: reading a document works with no script), so the server |
| 6 | //! must be able to render what the browser's editor wrote. |
| 7 | //! |
| 8 | //! Unknown nodes render their children rather than disappearing. A |
| 9 | //! future editor build can introduce a node this binary has never |
| 10 | //! heard of, and a reader silently losing that text would be data |
| 11 | //! loss, not a degradation. |
| 12 | |
| 13 | use std::fmt::Write as _; |
| 14 | use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef}; |
| 15 | use yrs::{Doc, GetString, ReadTxn, Text, Transact, Xml, XmlElementRef, XmlFragmentRef}; |
| 16 | |
| 17 | /// The CRDT root name. y-prosemirror's default, and part of the wire |
| 18 | /// contract — changing it silently orphans every existing document. |
| 19 | pub const ROOT: &str = "prosemirror"; |
| 20 | |
| 21 | fn root(doc: &Doc) -> XmlFragmentRef { |
| 22 | doc.get_or_insert_xml_fragment(ROOT) |
| 23 | } |
| 24 | |
| 25 | fn escape(s: &str, out: &mut String) { |
| 26 | for c in s.chars() { |
| 27 | match c { |
| 28 | '&' => out.push_str("&"), |
| 29 | '<' => out.push_str("<"), |
| 30 | '>' => out.push_str(">"), |
| 31 | '"' => out.push_str("""), |
| 32 | '\'' => out.push_str("'"), |
| 33 | _ => out.push(c), |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /// Marks this build knows, in a fixed order so output is deterministic: |
| 39 | /// `(mark name, opening markup, closing markup)`. A mark not listed here |
| 40 | /// contributes no wrapper but never suppresses its text. |
| 41 | /// |
| 42 | /// Every one of these is attribute-free on the CRDT side — the mark's |
| 43 | /// presence is the whole of its meaning — and the markup each one emits |
| 44 | /// is a compile-time constant chosen by lookup from this table. That is |
| 45 | /// what keeps `Store.HTML`'s `template.HTML` return honest |
| 46 | /// (`internal/docs/store.go`): `fragment_html` escapes every text run |
| 47 | /// AND puts no byte from a document into attribute position, so the |
| 48 | /// markup is safe to write out unescaped. A mark carrying a value — |
| 49 | /// `link`'s `href`, or a `fontFamily` naming its own face — would break |
| 50 | /// that, and `POST /d/{sqid}/updates` accepts arbitrary client-supplied |
| 51 | /// Yjs bytes that never went through ProseMirror's schema, so the value |
| 52 | /// would be genuinely arbitrary. Adding one is a real decision about |
| 53 | /// attribute escaping and (for `href`) a scheme allowlist, not a row in |
| 54 | /// this table. |
| 55 | /// |
| 56 | /// Paragraph alignment (ALIGNMENTS below) is that same discipline |
| 57 | /// applied to a property that genuinely cannot be a mark: it belongs to |
| 58 | /// the block, so it arrives as an attribute WITH a value — and that |
| 59 | /// value is used only as a key into a table whose right-hand side is a |
| 60 | /// literal in this binary. |
| 61 | /// |
| 62 | /// The font and size marks are ordered ahead of the semantic ones so a |
| 63 | /// run carrying both nests as `<span class="…"><strong>…</strong></span>` |
| 64 | /// rather than the other way round. |
| 65 | /// |
| 66 | /// All three faces have a mark; "normal" size still has none. The rule |
| 67 | /// is that the DEFAULT carries no mark — it is what a document is when |
| 68 | /// nobody has said otherwise, and spending a mark on it would put a |
| 69 | /// span around most of the text in most documents. What this table does |
| 70 | /// NOT do is decide which face that is: the default is named by |
| 71 | /// `internal/fonts`' `Default`, `views/show.html`'s `[docs-text]` rule |
| 72 | /// and `fixtures/fonts.mjs`'s `DEFAULT_FONT`, and every face here is |
| 73 | /// spelled out so that moving it is a change to those three and not to |
| 74 | /// this one. `font_serif` exists for exactly that reason: serif used to |
| 75 | /// BE the absence of a mark, and became unsayable the moment it stopped |
| 76 | /// being the default. |
| 77 | const MARKS: &[(&str, &str, &str)] = &[ |
| 78 | ("font_serif", "<span class=\"docs-font-serif\">", "</span>"), |
| 79 | ("font_sans", "<span class=\"docs-font-sans\">", "</span>"), |
| 80 | ("font_mono", "<span class=\"docs-font-mono\">", "</span>"), |
| 81 | // The point ladder. Eleven rows rather than one mark carrying a |
| 82 | // number, and that is the whole of this table's discipline applied |
| 83 | // to the feature people most expect to be a value: a mark spelling |
| 84 | // "14pt" would put a document's own byte into attribute position, |
| 85 | // which is exactly what the comment above forbids. Eleven |
| 86 | // attribute-free marks spend no new safety property at all. |
| 87 | // |
| 88 | // 12pt is absent because it is the default, and the default carries |
| 89 | // no mark — the same rule that keeps "normal" and the default face |
| 90 | // out of this table. |
| 91 | ("size_8", "<span class=\"docs-size-8\">", "</span>"), |
| 92 | ("size_9", "<span class=\"docs-size-9\">", "</span>"), |
| 93 | ("size_10", "<span class=\"docs-size-10\">", "</span>"), |
| 94 | ("size_11", "<span class=\"docs-size-11\">", "</span>"), |
| 95 | ("size_14", "<span class=\"docs-size-14\">", "</span>"), |
| 96 | ("size_18", "<span class=\"docs-size-18\">", "</span>"), |
| 97 | ("size_24", "<span class=\"docs-size-24\">", "</span>"), |
| 98 | ("size_36", "<span class=\"docs-size-36\">", "</span>"), |
| 99 | ("size_48", "<span class=\"docs-size-48\">", "</span>"), |
| 100 | ("size_72", "<span class=\"docs-size-72\">", "</span>"), |
| 101 | // The four-step scale the ladder replaced. These stay here |
| 102 | // PERMANENTLY, and not out of politeness: documents in the wild |
| 103 | // carry them, and a mark this table stops rendering is a mark whose |
| 104 | // text silently loses its size for every reader without JavaScript |
| 105 | // — the module comment's own "silence here is data loss". They are |
| 106 | // simply no longer offered by the control, and the stylesheet maps |
| 107 | // each to its nearest ladder step so one document does not read at |
| 108 | // two sizes depending on which renderer looked at it. |
| 109 | ("size_sm", "<span class=\"docs-size-sm\">", "</span>"), |
| 110 | ("size_lg", "<span class=\"docs-size-lg\">", "</span>"), |
| 111 | ("size_xl", "<span class=\"docs-size-xl\">", "</span>"), |
| 112 | ("strong", "<strong>", "</strong>"), |
| 113 | ("em", "<em>", "</em>"), |
| 114 | ("code", "<code>", "</code>"), |
| 115 | ]; |
| 116 | |
| 117 | /// Paragraph alignments this build knows: `(attribute value, class |
| 118 | /// name)`. The first block-level formatting, and the first thing in |
| 119 | /// this file that reads a document attribute whose value is not a |
| 120 | /// number. |
| 121 | /// |
| 122 | /// That is why it is a table and not an interpolation. Alignment cannot |
| 123 | /// be a mark — it belongs to the block, not the run — but it has to |
| 124 | /// keep the property the MARKS table above exists to protect: nothing |
| 125 | /// from a document may reach attribute position in `fragment_html`, |
| 126 | /// whose output `internal/docs/store.go` returns as `template.HTML` and |
| 127 | /// `views/show.html` writes into the page unescaped. So the value is |
| 128 | /// only ever a key looked up here, and what gets emitted is the |
| 129 | /// right-hand side, which is a literal in this binary. |
| 130 | /// |
| 131 | /// Closed, therefore, in the strong sense: a value that is not in this |
| 132 | /// table — a typo, a hand-crafted update through |
| 133 | /// `POST /d/{sqid}/updates`, or a future version's `justify` — is |
| 134 | /// dropped entirely. Never escaped and emitted, never passed through. |
| 135 | /// Adding `justify` here later is one row plus one CSS rule plus the |
| 136 | /// arithmetic in `internal/export/pdf.go`; letting an unknown value |
| 137 | /// through would be a decision about attribute escaping, which is the |
| 138 | /// decision this shape refuses to have to make. |
| 139 | /// |
| 140 | /// `left` is deliberately absent. It is what a paragraph is when nobody |
| 141 | /// has said otherwise, so it spends no class and no `attrs` — the same |
| 142 | /// reasoning that keeps "normal" out of MARKS, and what makes |
| 143 | /// a document written before alignment existed render byte-identically |
| 144 | /// to one whose author chose left on purpose. |
| 145 | const ALIGNMENTS: &[(&str, &str)] = &[ |
| 146 | ("center", "docs-align-center"), |
| 147 | ("right", "docs-align-right"), |
| 148 | ]; |
| 149 | |
| 150 | /// The alignment value, if this build knows it. `None` for left, for an |
| 151 | /// absent attribute, and for anything unrecognised — the three cases |
| 152 | /// that all render the same way. |
| 153 | fn alignment<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<&'static str> { |
| 154 | let value = e.get_attribute(txn, "align")?; |
| 155 | ALIGNMENTS |
| 156 | .iter() |
| 157 | .find(|(name, _)| *name == value) |
| 158 | .map(|(name, _)| *name) |
| 159 | } |
| 160 | |
| 161 | /// The deepest indent this build renders. Past eight half-inch steps a |
| 162 | /// paragraph on US Letter has no measure left, and `fixtures/indent.mjs` |
| 163 | /// states the same limit for the editor. |
| 164 | const MAX_INDENT: u8 = 8; |
| 165 | |
| 166 | /// The indent level, if this block has one: a whole number from 1 to |
| 167 | /// `MAX_INDENT`, or `None` for 0, for an absent attribute, and for |
| 168 | /// anything that does not parse. |
| 169 | /// |
| 170 | /// A NUMBER, where `alignment` above handles a string, and that is the |
| 171 | /// whole reason this is a separate function rather than another closed |
| 172 | /// table. Alignment's values are strings, so the only safe thing to do |
| 173 | /// with one is look it up and emit the table's own right-hand side. |
| 174 | /// Indent's value is an integer, so it is handled the way `heading`'s |
| 175 | /// level already is: parsed, then clamped. Both reach the property this |
| 176 | /// module exists to hold — no byte from a document reaches attribute |
| 177 | /// position — by different routes, and using the EXISTING route rather |
| 178 | /// than inventing a third one is the point. A `u8` that has been through |
| 179 | /// `clamp(1, 8)` can only be one of eight values, so interpolating it |
| 180 | /// into a class name below is not the injection that interpolating an |
| 181 | /// arbitrary attribute would be. |
| 182 | /// |
| 183 | /// Clamped rather than rejected, matching `heading`'s level: a document |
| 184 | /// arriving through `POST /d/{sqid}/updates` with `indent` 400 renders |
| 185 | /// at 8, and so does one with 4000000. Anything that is not a run of |
| 186 | /// digits — `"420; drop"`, `" 3"`, `"3.0"`, an empty string — is `None` |
| 187 | /// and renders flush left. `fixtures/indent.mjs`'s `normaliseIndent` |
| 188 | /// applies the same two rules, deliberately, so the two agree on every |
| 189 | /// input rather than only on the ones a real editor produces. |
| 190 | /// |
| 191 | /// (`heading`'s own level below still parses as `u8` and so still |
| 192 | /// rejects 400 where it clamps 99. That is the shape this function had |
| 193 | /// and stopped having; it is left alone here because changing it would |
| 194 | /// change what existing documents render as, but it is not the model to |
| 195 | /// copy.) |
| 196 | fn indent_level<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<u8> { |
| 197 | let raw = e.get_attribute(txn, "indent")?; |
| 198 | // DIGITS ONLY, then parse wide, then clamp — and each of those |
| 199 | // three is answering a disagreement with the editor rather than |
| 200 | // being careful for its own sake. |
| 201 | // |
| 202 | // This parsed as `u8` for one review cycle, and the doc comment |
| 203 | // above claimed "indent 400 renders at 8". It did not: 400 |
| 204 | // overflows a u8, so the parse failed and the block rendered flush |
| 205 | // left — while `fixtures/indent.mjs` used `Number()` and rendered |
| 206 | // it at 8. The same split existed for " 3", "3.0", "1e1" and "0x3", |
| 207 | // all of which JavaScript accepts and Rust rejected. No safety |
| 208 | // property was ever spent (nothing here emits the byte either way), |
| 209 | // but the claim that a document cannot indent one distance on |
| 210 | // screen and another on paper was false for every one of them. |
| 211 | // |
| 212 | // Parsing as u32 makes "400" a number rather than an error, so it |
| 213 | // clamps like every other too-large value; requiring digits makes |
| 214 | // " 3" and "3.0" fail on BOTH sides, which is what indent.mjs's |
| 215 | // matching strictness now guarantees. |
| 216 | if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { |
| 217 | return None; |
| 218 | } |
| 219 | // unwrap_or(u32::MAX), not `.ok()?`. A string that passed the |
| 220 | // digits-only check above and still fails to parse can only have |
| 221 | // failed by being too large — so treating it as "as large as |
| 222 | // possible" clamps it, where propagating the error would render it |
| 223 | // flush left. That is the last corner where this and |
| 224 | // fixtures/indent.mjs disagreed: JavaScript's Number() turns a |
| 225 | // twenty-digit run into a float far above the maximum and clamps to |
| 226 | // 8, and now so does this. |
| 227 | let level = raw |
| 228 | .parse::<u32>() |
| 229 | .unwrap_or(u32::MAX) |
| 230 | .min(MAX_INDENT as u32) as u8; |
| 231 | if level == 0 { |
| 232 | None |
| 233 | } else { |
| 234 | Some(level) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | /// The class `fragment_html` puts on an indented block. `None` when the |
| 239 | /// block is not indented, so an un-indented paragraph emits exactly the |
| 240 | /// markup it did before this existed. |
| 241 | fn indent_class<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<String> { |
| 242 | indent_level(txn, e).map(|n| format!("docs-indent-{n}")) |
| 243 | } |
| 244 | |
| 245 | /// The whole `class` attribute for a block, or `None` when it needs |
| 246 | /// none. `extra` is whatever the caller already worked out (the |
| 247 | /// alignment class, for a paragraph); the indent class is added here. |
| 248 | /// |
| 249 | /// One function rather than each call site joining its own, because |
| 250 | /// the two classes have to be separated by exactly one space and a |
| 251 | /// block with only one of them must emit no stray space — the sort of |
| 252 | /// thing that is obviously right in each of two places and wrong in |
| 253 | /// the one where they meet. |
| 254 | fn block_class<T: ReadTxn>( |
| 255 | txn: &T, |
| 256 | e: &XmlElementRef, |
| 257 | extra: Option<&'static str>, |
| 258 | ) -> Option<String> { |
| 259 | match (extra, indent_class(txn, e)) { |
| 260 | (Some(a), Some(i)) => Some(format!("{a} {i}")), |
| 261 | (Some(a), None) => Some(a.to_string()), |
| 262 | (None, Some(i)) => Some(i), |
| 263 | (None, None) => None, |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | /// The class `fragment_html` puts on an aligned paragraph, from the |
| 268 | /// same table. |
| 269 | fn align_class<T: ReadTxn>(txn: &T, e: &XmlElementRef) -> Option<&'static str> { |
| 270 | let value = e.get_attribute(txn, "align")?; |
| 271 | ALIGNMENTS |
| 272 | .iter() |
| 273 | .find(|(name, _)| *name == value) |
| 274 | .map(|(_, class)| *class) |
| 275 | } |
| 276 | |
| 277 | fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) { |
| 278 | // Diff gives runs with their attributes, which is where marks live. |
| 279 | for chunk in t.diff(txn, yrs::types::text::YChange::identity) { |
| 280 | let mut open = Vec::new(); |
| 281 | if let Some(attrs) = &chunk.attributes { |
| 282 | for (name, o, c) in MARKS { |
| 283 | if attrs.contains_key(*name) { |
| 284 | open.push((*o, *c)); |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | for (o, _) in &open { |
| 289 | out.push_str(o); |
| 290 | } |
| 291 | if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert { |
| 292 | escape(s, out); |
| 293 | } |
| 294 | for (_, c) in open.iter().rev() { |
| 295 | out.push_str(c); |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | fn node_html<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String) { |
| 301 | match node { |
| 302 | XmlOut::Text(t) => text_html(txn, t, out), |
| 303 | XmlOut::Element(e) => { |
| 304 | let name = e.tag().to_string(); |
| 305 | let (open, close) = match name.as_str() { |
| 306 | // A paragraph can be aligned, indented, both, or |
| 307 | // neither. Both classes are literals this binary builds |
| 308 | // — one from the ALIGNMENTS table, one from a u8 that |
| 309 | // has already been clamped to 1..=8 — so the joined |
| 310 | // value can only be one of a small closed set of |
| 311 | // strings, and nothing from the document reaches |
| 312 | // attribute position. |
| 313 | "paragraph" => match block_class(txn, e, align_class(txn, e)) { |
| 314 | Some(class) => (format!("<p class=\"{class}\">"), "</p>".to_string()), |
| 315 | None => ("<p>".to_string(), "</p>".to_string()), |
| 316 | }, |
| 317 | "heading" => { |
| 318 | // Clamp: a corrupt or hostile level must not emit |
| 319 | // <h0> or <h99>. |
| 320 | let level = e |
| 321 | .get_attribute(txn, "level") |
| 322 | .and_then(|v| v.parse::<u8>().ok()) |
| 323 | .unwrap_or(1) |
| 324 | .clamp(1, 6); |
| 325 | // Headings indent too — every word processor lets |
| 326 | // them — but they do not align: alignment is on the |
| 327 | // paragraph spec alone, here and in the editor's |
| 328 | // schema, and adding it is a change to both. |
| 329 | match block_class(txn, e, None) { |
| 330 | Some(class) => ( |
| 331 | format!("<h{level} class=\"{class}\">"), |
| 332 | format!("</h{level}>"), |
| 333 | ), |
| 334 | None => (format!("<h{level}>"), format!("</h{level}>")), |
| 335 | } |
| 336 | } |
| 337 | "bullet_list" => ("<ul>".to_string(), "</ul>".to_string()), |
| 338 | "ordered_list" => ("<ol>".to_string(), "</ol>".to_string()), |
| 339 | "list_item" => ("<li>".to_string(), "</li>".to_string()), |
| 340 | "blockquote" => ("<blockquote>".to_string(), "</blockquote>".to_string()), |
| 341 | // Shift+Enter's node (prosemirror-schema-basic's own |
| 342 | // `hard_break`; the tag is the node's name because |
| 343 | // y-prosemirror builds every non-text node as an |
| 344 | // XmlElement named after its type). `<br>` is void, so |
| 345 | // it has an opening and no closing markup — the loop |
| 346 | // below still walks its children, which a real editor |
| 347 | // never gives it and a client posting raw Yjs bytes |
| 348 | // could, and they render after the break rather than |
| 349 | // vanishing, per the module comment. |
| 350 | "hard_break" => ("<br>".to_string(), String::new()), |
| 351 | // Unknown: render children with no wrapper. See the |
| 352 | // module comment — silence here is data loss. |
| 353 | _ => (String::new(), String::new()), |
| 354 | }; |
| 355 | out.push_str(&open); |
| 356 | for child in e.children(txn) { |
| 357 | node_html(txn, &child, out); |
| 358 | } |
| 359 | out.push_str(&close); |
| 360 | } |
| 361 | XmlOut::Fragment(f) => { |
| 362 | for child in f.children(txn) { |
| 363 | node_html(txn, &child, out); |
| 364 | } |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | /// The document rendered as HTML, escaped and ready to place inside the |
| 370 | /// page container. |
| 371 | pub fn fragment_html(doc: &Doc) -> String { |
| 372 | let f = root(doc); |
| 373 | let txn = doc.transact(); |
| 374 | let mut out = String::new(); |
| 375 | for child in f.children(&txn) { |
| 376 | node_html(&txn, &child, &mut out); |
| 377 | } |
| 378 | out |
| 379 | } |
| 380 | |
| 381 | fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) { |
| 382 | match node { |
| 383 | XmlOut::Text(t) => cur.push_str(&t.get_string(txn)), |
| 384 | XmlOut::Element(e) => { |
| 385 | let name = e.tag().to_string(); |
| 386 | // A hard break ends the current line and the block carries |
| 387 | // on: one newline, where the block boundary below is a |
| 388 | // blank line. It is deliberately NOT in the is_block set — |
| 389 | // treating it as a block would turn one paragraph into two |
| 390 | // wherever a person pressed Shift+Enter. |
| 391 | if name == "hard_break" { |
| 392 | cur.push('\n'); |
| 393 | return; |
| 394 | } |
| 395 | let is_block = matches!( |
| 396 | name.as_str(), |
| 397 | "paragraph" | "heading" | "list_item" | "blockquote" |
| 398 | ); |
| 399 | // A text sibling that precedes this block with no block of |
| 400 | // its own (unreachable through ProseMirror's own schema, |
| 401 | // but reachable the moment a client posts arbitrary Yjs |
| 402 | // bytes — see fragment_text's own doc comment) must not |
| 403 | // run straight into this block's text with no separator: |
| 404 | // flush whatever is already pending as its own segment |
| 405 | // before this block contributes anything. |
| 406 | if is_block && !cur.is_empty() { |
| 407 | blocks.push(std::mem::take(cur)); |
| 408 | } |
| 409 | let mut had_children = false; |
| 410 | for child in e.children(txn) { |
| 411 | had_children = true; |
| 412 | node_text(txn, &child, blocks, cur); |
| 413 | } |
| 414 | if is_block { |
| 415 | if !cur.is_empty() { |
| 416 | blocks.push(std::mem::take(cur)); |
| 417 | } else if !had_children { |
| 418 | // A structurally empty block (`<p></p>`) still |
| 419 | // renders as a blank paragraph in fragment_html; |
| 420 | // mirror that here rather than silently dropping |
| 421 | // it, which would make the two renderers disagree |
| 422 | // about how many paragraphs a document has. A |
| 423 | // block whose children already flushed their own |
| 424 | // text (had_children but cur now empty, e.g. a |
| 425 | // list_item wrapping a paragraph) must not get a |
| 426 | // second, spurious empty entry — that is exactly |
| 427 | // what had_children distinguishes. |
| 428 | blocks.push(String::new()); |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | XmlOut::Fragment(f) => { |
| 433 | for child in f.children(txn) { |
| 434 | node_text(txn, &child, blocks, cur); |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | /// The document as plain text, one blank line between blocks. Used by |
| 441 | /// the list's preview and by anything that wants the words without the |
| 442 | /// markup. |
| 443 | /// |
| 444 | /// Neither ProseMirror's own schema nor its `y-prosemirror` binding can |
| 445 | /// produce a fragment with a bare text node preceding a block, or a |
| 446 | /// structurally empty block — so, until this task, node_text's two |
| 447 | /// edge cases here were unreachable in practice: a text sibling ahead |
| 448 | /// of a block used to run straight into that block's own text with no |
| 449 | /// separator (`"orphanactual"` for `[text("orphan"), paragraph(text("actual"))]`), |
| 450 | /// and an empty `<p></p>` that fragment_html preserves used to vanish |
| 451 | /// here instead of leaving a blank line. `POST /d/{sqid}/updates` |
| 452 | /// (Task 5) is what makes them reachable: it accepts arbitrary |
| 453 | /// client-supplied Yjs bytes, not only what a real ProseMirror editor |
| 454 | /// would ever emit. Both are fixed in node_text above and covered by |
| 455 | /// core/tests/fragment.rs. |
| 456 | pub fn fragment_text(doc: &Doc) -> String { |
| 457 | let f = root(doc); |
| 458 | let txn = doc.transact(); |
| 459 | let mut blocks = Vec::new(); |
| 460 | let mut cur = String::new(); |
| 461 | for child in f.children(&txn) { |
| 462 | node_text(&txn, &child, &mut blocks, &mut cur); |
| 463 | } |
| 464 | if !cur.is_empty() { |
| 465 | blocks.push(cur); |
| 466 | } |
| 467 | blocks.join("\n\n") |
| 468 | } |
| 469 | |
| 470 | // --- the structured renderer --- |
| 471 | // |
| 472 | // `fragment_html` above answers "what does this document look like"; |
| 473 | // `fragment_json` below answers "what is this document made of". The |
| 474 | // export paths (`internal/export`, `internal/pdf`) need the second |
| 475 | // question answered: where a list ends, which run is bold, which |
| 476 | // paragraph is a heading. Re-deriving that in Go by parsing the markup |
| 477 | // this file emits would mean a parser to keep honest against an |
| 478 | // emitter, and Go's standard library has no HTML parser to do it with. |
| 479 | // |
| 480 | // The shape is ProseMirror's own JSON — `{"type":..,"attrs":..,"content":[..]}`, |
| 481 | // text as `{"type":"text","text":..,"marks":[..]}` — because that is the |
| 482 | // vocabulary the editor on the other side of the wire already speaks, |
| 483 | // so a Go consumer and a future JS one describe the same document the |
| 484 | // same way. Written by hand rather than through serde: the crate has |
| 485 | // no serialisation dependency outside dev-dependencies, and this is |
| 486 | // one escape function and three walks. |
| 487 | |
| 488 | /// JSON string escaping, per RFC 8259: the two mandatory escapes, the |
| 489 | /// three shorthands, and `\u00XX` for everything else below U+0020. |
| 490 | /// Everything at or above U+0020 other than `"` and `\` goes through |
| 491 | /// as-is — the output is UTF-8 and JSON says that is legal. |
| 492 | fn escape_json(s: &str, out: &mut String) { |
| 493 | for c in s.chars() { |
| 494 | match c { |
| 495 | '"' => out.push_str("\\\""), |
| 496 | '\\' => out.push_str("\\\\"), |
| 497 | '\n' => out.push_str("\\n"), |
| 498 | '\r' => out.push_str("\\r"), |
| 499 | '\t' => out.push_str("\\t"), |
| 500 | c if (c as u32) < 0x20 => { |
| 501 | let _ = write!(out, "\\u{:04x}", c as u32); |
| 502 | } |
| 503 | c => out.push(c), |
| 504 | } |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | /// The mark names on one text run: the ones this build knows in `MARKS` |
| 509 | /// order, then any it does not, sorted. |
| 510 | /// |
| 511 | /// The sort is not cosmetic. `Attrs` is a `HashMap`, so iterating it |
| 512 | /// yields an order that varies between runs of the same binary on the |
| 513 | /// same document — an unsorted tail would make this renderer's output |
| 514 | /// nondeterministic, which would make every golden-file test on the Go |
| 515 | /// side flake rather than fail. Unknown marks are carried through |
| 516 | /// rather than dropped for the same reason unknown *nodes* render their |
| 517 | /// children (see the module comment): a consumer that ignores a mark it |
| 518 | /// does not recognise still has the text, and one that grows to |
| 519 | /// understand it needs it to have been there. |
| 520 | /// WHOEVER ADDS THE FIRST VALUED MARK: THIS FUNCTION IS WHERE THE |
| 521 | /// ALLOWLIST GOES, AND IT MUST NOT ASSUME ONLY THAT MARK HAS A VALUE. |
| 522 | /// |
| 523 | /// This returns NAMES. Every mark's value — including an unknown mark's |
| 524 | /// — is dropped here and reaches no consumer, which is why an unknown |
| 525 | /// mark carried through by name is safe today: `fragment_html` finds no |
| 526 | /// MARKS row and emits no wrapper, and `text_json` writes the name |
| 527 | /// through `escape_json`. |
| 528 | /// |
| 529 | /// A valued mark changes that. The moment one value is allowed past |
| 530 | /// this point, the shape of the question becomes "which marks may carry |
| 531 | /// a value, and what may that value be" — and the tempting answer, that |
| 532 | /// `link` is the one with an `href`, is wrong on arrival. This function |
| 533 | /// collects marks it has never heard of, straight off the wire, and |
| 534 | /// `POST /d/{sqid}/updates` accepts bytes that never went through |
| 535 | /// ProseMirror's schema. So an unknown mark can carry an arbitrary |
| 536 | /// value too, and an allowlist written around the known one would let |
| 537 | /// it past. |
| 538 | /// |
| 539 | /// It is also the right place, and that is the other half of the note: |
| 540 | /// this is the single point the HTML renderer and every JSON consumer |
| 541 | /// agree on what marks a run carries, so a check here cannot be |
| 542 | /// re-implemented differently by `markdown.go` and `pdf.go` the way one |
| 543 | /// in `node_html` would have to be. Plan 7's D1 names it. |
| 544 | /// |
| 545 | /// (Found by the session that owns Plan 7, reading this file after the |
| 546 | /// point-ladder work restructured around it — recorded here rather than |
| 547 | /// only in that plan, because the plan may land long after someone else |
| 548 | /// touches this function.) |
| 549 | fn mark_names(attrs: &yrs::types::Attrs) -> Vec<String> { |
| 550 | let mut names: Vec<String> = MARKS |
| 551 | .iter() |
| 552 | .filter(|(name, _, _)| attrs.contains_key(*name)) |
| 553 | .map(|(name, _, _)| (*name).to_string()) |
| 554 | .collect(); |
| 555 | let mut unknown: Vec<String> = attrs |
| 556 | .keys() |
| 557 | .map(|k| k.to_string()) |
| 558 | .filter(|k| !MARKS.iter().any(|(name, _, _)| *name == k.as_str())) |
| 559 | .collect(); |
| 560 | unknown.sort(); |
| 561 | names.append(&mut unknown); |
| 562 | names |
| 563 | } |
| 564 | |
| 565 | fn text_json<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String, first: &mut bool) { |
| 566 | for chunk in t.diff(txn, yrs::types::text::YChange::identity) { |
| 567 | let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert else { |
| 568 | // A non-string insert is an embed. `fragment_html` renders |
| 569 | // nothing for one today, and inventing a JSON shape for |
| 570 | // something with no renderer would be guessing at a schema |
| 571 | // rather than reporting one. |
| 572 | continue; |
| 573 | }; |
| 574 | if s.is_empty() { |
| 575 | continue; |
| 576 | } |
| 577 | if !*first { |
| 578 | out.push(','); |
| 579 | } |
| 580 | *first = false; |
| 581 | out.push_str("{\"type\":\"text\",\"text\":\""); |
| 582 | escape_json(s, out); |
| 583 | out.push('"'); |
| 584 | if let Some(attrs) = &chunk.attributes { |
| 585 | let names = mark_names(attrs); |
| 586 | if !names.is_empty() { |
| 587 | out.push_str(",\"marks\":["); |
| 588 | for (i, name) in names.iter().enumerate() { |
| 589 | if i > 0 { |
| 590 | out.push(','); |
| 591 | } |
| 592 | out.push_str("{\"type\":\""); |
| 593 | escape_json(name, out); |
| 594 | out.push_str("\"}"); |
| 595 | } |
| 596 | out.push(']'); |
| 597 | } |
| 598 | } |
| 599 | out.push('}'); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | // Unlike the two renderers above, this one needs no case of its own |
| 604 | // for `hard_break`: an element with no children already comes out as |
| 605 | // `{"type":"hard_break","content":[]}`, which names the node and is |
| 606 | // what internal/export reads. The empty `content` is how this renderer |
| 607 | // spells every childless element (an empty paragraph included), so a |
| 608 | // leaf is not a special shape here. |
| 609 | fn node_json<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String, first: &mut bool) { |
| 610 | match node { |
| 611 | XmlOut::Text(t) => text_json(txn, t, out, first), |
| 612 | XmlOut::Element(e) => { |
| 613 | let name = e.tag().to_string(); |
| 614 | if !*first { |
| 615 | out.push(','); |
| 616 | } |
| 617 | *first = false; |
| 618 | out.push_str("{\"type\":\""); |
| 619 | escape_json(&name, out); |
| 620 | out.push('"'); |
| 621 | // Attributes are collected and written ONCE, as a single |
| 622 | // object. |
| 623 | // |
| 624 | // This used to be two independent `if name == …` arms, each |
| 625 | // writing a complete `,"attrs":{…}`. That was correct only |
| 626 | // for as long as no node could carry two attributes: a |
| 627 | // heading has a level and a paragraph has an alignment, and |
| 628 | // never both. Indent broke that — a paragraph can be |
| 629 | // centred AND indented, and a heading can be indented too — |
| 630 | // and the old shape would have emitted `"attrs"` twice in |
| 631 | // one object. Every JSON parser accepts that and they do not |
| 632 | // agree on which wins, so it would not have failed loudly; |
| 633 | // internal/export would have quietly lost one of the two. |
| 634 | // |
| 635 | // Each value below is a number or a literal from a closed |
| 636 | // table in this binary, never a byte from the document, so |
| 637 | // nothing here needs escaping — the same property |
| 638 | // `fragment_html` depends on, held for the same reason. |
| 639 | let mut attrs: Vec<String> = Vec::new(); |
| 640 | if name == "heading" { |
| 641 | // Clamped exactly as `node_html` clamps it, and for the |
| 642 | // same reason: a corrupt or hostile level must not reach |
| 643 | // a consumer as `h0` or `h99`. Both renderers clamping |
| 644 | // independently is deliberate — a document must not |
| 645 | // paginate as one level on screen and export as another. |
| 646 | let level = e |
| 647 | .get_attribute(txn, "level") |
| 648 | .and_then(|v| v.parse::<u8>().ok()) |
| 649 | .unwrap_or(1) |
| 650 | .clamp(1, 6); |
| 651 | attrs.push(format!("\"level\":{level}")); |
| 652 | } |
| 653 | if name == "paragraph" { |
| 654 | // Same allowlist, same drop, and — like the heading |
| 655 | // clamp above — arrived at independently in both |
| 656 | // renderers, so a paragraph cannot read as centred on |
| 657 | // screen and export flush left. `align` is the |
| 658 | // allowlist's own key, a literal in this binary, so |
| 659 | // nothing here needs escaping and nothing from the |
| 660 | // document can appear. |
| 661 | if let Some(align) = alignment(txn, e) { |
| 662 | attrs.push(format!("\"align\":\"{align}\"")); |
| 663 | } |
| 664 | } |
| 665 | if name == "paragraph" || name == "heading" { |
| 666 | if let Some(indent) = indent_level(txn, e) { |
| 667 | attrs.push(format!("\"indent\":{indent}")); |
| 668 | } |
| 669 | } |
| 670 | if !attrs.is_empty() { |
| 671 | let _ = write!(out, ",\"attrs\":{{{}}}", attrs.join(",")); |
| 672 | } |
| 673 | out.push_str(",\"content\":["); |
| 674 | let mut inner = true; |
| 675 | for child in e.children(txn) { |
| 676 | node_json(txn, &child, out, &mut inner); |
| 677 | } |
| 678 | out.push_str("]}"); |
| 679 | } |
| 680 | XmlOut::Fragment(f) => { |
| 681 | // Flattened into the parent's content, exactly as |
| 682 | // `node_html` renders a nested fragment's children with no |
| 683 | // wrapper of its own. |
| 684 | for child in f.children(txn) { |
| 685 | node_json(txn, &child, out, first); |
| 686 | } |
| 687 | } |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | /// The document as ProseMirror-shaped JSON: `{"type":"doc","content":[..]}`. |
| 692 | /// |
| 693 | /// Deterministic for a given document state — see `mark_names` for the |
| 694 | /// one place that took work — so a Go consumer's golden files are |
| 695 | /// stable. |
| 696 | pub fn fragment_json(doc: &Doc) -> String { |
| 697 | let f = root(doc); |
| 698 | let txn = doc.transact(); |
| 699 | let mut out = String::from("{\"type\":\"doc\",\"content\":["); |
| 700 | let mut first = true; |
| 701 | for child in f.children(&txn) { |
| 702 | node_json(&txn, &child, &mut out, &mut first); |
| 703 | } |
| 704 | out.push_str("]}"); |
| 705 | out |
| 706 | } |
| 707 | |