//! Rendering a ProseMirror-shaped Y.XmlFragment to text and HTML. //! //! This is the only place on the Rust side that knows ProseMirror's //! node vocabulary. It exists because the read view is server-rendered //! (spec §6.2: reading a document works with no script), so the server //! must be able to render what the browser's editor wrote. //! //! Unknown nodes render their children rather than disappearing. A //! future editor build can introduce a node this binary has never //! heard of, and a reader silently losing that text would be data //! loss, not a degradation. use std::fmt::Write as _; use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef}; use yrs::{Doc, GetString, ReadTxn, Text, Transact, Xml, XmlElementRef, XmlFragmentRef}; /// The CRDT root name. y-prosemirror's default, and part of the wire /// contract — changing it silently orphans every existing document. pub const ROOT: &str = "prosemirror"; fn root(doc: &Doc) -> XmlFragmentRef { doc.get_or_insert_xml_fragment(ROOT) } fn escape(s: &str, out: &mut String) { for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } } /// Marks this build knows, in a fixed order so output is deterministic: /// `(mark name, opening markup, closing markup)`. A mark not listed here /// contributes no wrapper but never suppresses its text. /// /// Every one of these is attribute-free on the CRDT side — the mark's /// presence is the whole of its meaning — and the markup each one emits /// is a compile-time constant chosen by lookup from this table. That is /// what keeps `Store.HTML`'s `template.HTML` return honest /// (`internal/docs/store.go`): `fragment_html` escapes every text run /// AND puts no byte from a document into attribute position, so the /// markup is safe to write out unescaped. A mark carrying a value — /// `link`'s `href`, or a `fontFamily` naming its own face — would break /// that, and `POST /d/{sqid}/updates` accepts arbitrary client-supplied /// Yjs bytes that never went through ProseMirror's schema, so the value /// would be genuinely arbitrary. Adding one is a real decision about /// attribute escaping and (for `href`) a scheme allowlist, not a row in /// this table. /// /// Paragraph alignment (ALIGNMENTS below) is that same discipline /// applied to a property that genuinely cannot be a mark: it belongs to /// the block, so it arrives as an attribute WITH a value — and that /// value is used only as a key into a table whose right-hand side is a /// literal in this binary. /// /// The font and size marks are ordered ahead of the semantic ones so a /// run carrying both nests as `…` /// rather than the other way round. /// /// All three faces have a mark; "normal" size still has none. The rule /// is that the DEFAULT carries no mark — it is what a document is when /// nobody has said otherwise, and spending a mark on it would put a /// span around most of the text in most documents. What this table does /// NOT do is decide which face that is: the default is named by /// `internal/fonts`' `Default`, `views/show.html`'s `[docs-text]` rule /// and `fixtures/fonts.mjs`'s `DEFAULT_FONT`, and every face here is /// spelled out so that moving it is a change to those three and not to /// this one. `font_serif` exists for exactly that reason: serif used to /// BE the absence of a mark, and became unsayable the moment it stopped /// being the default. const MARKS: &[(&str, &str, &str)] = &[ ("font_serif", "", ""), ("font_sans", "", ""), ("font_mono", "", ""), // The point ladder. Eleven rows rather than one mark carrying a // number, and that is the whole of this table's discipline applied // to the feature people most expect to be a value: a mark spelling // "14pt" would put a document's own byte into attribute position, // which is exactly what the comment above forbids. Eleven // attribute-free marks spend no new safety property at all. // // 12pt is absent because it is the default, and the default carries // no mark — the same rule that keeps "normal" and the default face // out of this table. ("size_8", "", ""), ("size_9", "", ""), ("size_10", "", ""), ("size_11", "", ""), ("size_14", "", ""), ("size_18", "", ""), ("size_24", "", ""), ("size_36", "", ""), ("size_48", "", ""), ("size_72", "", ""), // The four-step scale the ladder replaced. These stay here // PERMANENTLY, and not out of politeness: documents in the wild // carry them, and a mark this table stops rendering is a mark whose // text silently loses its size for every reader without JavaScript // — the module comment's own "silence here is data loss". They are // simply no longer offered by the control, and the stylesheet maps // each to its nearest ladder step so one document does not read at // two sizes depending on which renderer looked at it. ("size_sm", "", ""), ("size_lg", "", ""), ("size_xl", "", ""), ("strong", "", ""), ("em", "", ""), ("code", "", ""), ]; /// Paragraph alignments this build knows: `(attribute value, class /// name)`. The first block-level formatting, and the first thing in /// this file that reads a document attribute whose value is not a /// number. /// /// That is why it is a table and not an interpolation. Alignment cannot /// be a mark — it belongs to the block, not the run — but it has to /// keep the property the MARKS table above exists to protect: nothing /// from a document may reach attribute position in `fragment_html`, /// whose output `internal/docs/store.go` returns as `template.HTML` and /// `views/show.html` writes into the page unescaped. So the value is /// only ever a key looked up here, and what gets emitted is the /// right-hand side, which is a literal in this binary. /// /// Closed, therefore, in the strong sense: a value that is not in this /// table — a typo, a hand-crafted update through /// `POST /d/{sqid}/updates`, or a future version's `justify` — is /// dropped entirely. Never escaped and emitted, never passed through. /// Adding `justify` here later is one row plus one CSS rule plus the /// arithmetic in `internal/export/pdf.go`; letting an unknown value /// through would be a decision about attribute escaping, which is the /// decision this shape refuses to have to make. /// /// `left` is deliberately absent. It is what a paragraph is when nobody /// has said otherwise, so it spends no class and no `attrs` — the same /// reasoning that keeps "normal" out of MARKS, and what makes /// a document written before alignment existed render byte-identically /// to one whose author chose left on purpose. const ALIGNMENTS: &[(&str, &str)] = &[ ("center", "docs-align-center"), ("right", "docs-align-right"), ]; /// The alignment value, if this build knows it. `None` for left, for an /// absent attribute, and for anything unrecognised — the three cases /// that all render the same way. fn alignment(txn: &T, e: &XmlElementRef) -> Option<&'static str> { let value = e.get_attribute(txn, "align")?; ALIGNMENTS .iter() .find(|(name, _)| *name == value) .map(|(name, _)| *name) } /// The deepest indent this build renders. Past eight half-inch steps a /// paragraph on US Letter has no measure left, and `fixtures/indent.mjs` /// states the same limit for the editor. const MAX_INDENT: u8 = 8; /// The indent level, if this block has one: a whole number from 1 to /// `MAX_INDENT`, or `None` for 0, for an absent attribute, and for /// anything that does not parse. /// /// A NUMBER, where `alignment` above handles a string, and that is the /// whole reason this is a separate function rather than another closed /// table. Alignment's values are strings, so the only safe thing to do /// with one is look it up and emit the table's own right-hand side. /// Indent's value is an integer, so it is handled the way `heading`'s /// level already is: parsed, then clamped. Both reach the property this /// module exists to hold — no byte from a document reaches attribute /// position — by different routes, and using the EXISTING route rather /// than inventing a third one is the point. A `u8` that has been through /// `clamp(1, 8)` can only be one of eight values, so interpolating it /// into a class name below is not the injection that interpolating an /// arbitrary attribute would be. /// /// Clamped rather than rejected, matching `heading`'s level: a document /// arriving through `POST /d/{sqid}/updates` with `indent` 400 renders /// at 8, and so does one with 4000000. Anything that is not a run of /// digits — `"420; drop"`, `" 3"`, `"3.0"`, an empty string — is `None` /// and renders flush left. `fixtures/indent.mjs`'s `normaliseIndent` /// applies the same two rules, deliberately, so the two agree on every /// input rather than only on the ones a real editor produces. /// /// (`heading`'s own level below still parses as `u8` and so still /// rejects 400 where it clamps 99. That is the shape this function had /// and stopped having; it is left alone here because changing it would /// change what existing documents render as, but it is not the model to /// copy.) fn indent_level(txn: &T, e: &XmlElementRef) -> Option { let raw = e.get_attribute(txn, "indent")?; // DIGITS ONLY, then parse wide, then clamp — and each of those // three is answering a disagreement with the editor rather than // being careful for its own sake. // // This parsed as `u8` for one review cycle, and the doc comment // above claimed "indent 400 renders at 8". It did not: 400 // overflows a u8, so the parse failed and the block rendered flush // left — while `fixtures/indent.mjs` used `Number()` and rendered // it at 8. The same split existed for " 3", "3.0", "1e1" and "0x3", // all of which JavaScript accepts and Rust rejected. No safety // property was ever spent (nothing here emits the byte either way), // but the claim that a document cannot indent one distance on // screen and another on paper was false for every one of them. // // Parsing as u32 makes "400" a number rather than an error, so it // clamps like every other too-large value; requiring digits makes // " 3" and "3.0" fail on BOTH sides, which is what indent.mjs's // matching strictness now guarantees. if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { return None; } // unwrap_or(u32::MAX), not `.ok()?`. A string that passed the // digits-only check above and still fails to parse can only have // failed by being too large — so treating it as "as large as // possible" clamps it, where propagating the error would render it // flush left. That is the last corner where this and // fixtures/indent.mjs disagreed: JavaScript's Number() turns a // twenty-digit run into a float far above the maximum and clamps to // 8, and now so does this. let level = raw .parse::() .unwrap_or(u32::MAX) .min(MAX_INDENT as u32) as u8; if level == 0 { None } else { Some(level) } } /// The class `fragment_html` puts on an indented block. `None` when the /// block is not indented, so an un-indented paragraph emits exactly the /// markup it did before this existed. fn indent_class(txn: &T, e: &XmlElementRef) -> Option { indent_level(txn, e).map(|n| format!("docs-indent-{n}")) } /// The whole `class` attribute for a block, or `None` when it needs /// none. `extra` is whatever the caller already worked out (the /// alignment class, for a paragraph); the indent class is added here. /// /// One function rather than each call site joining its own, because /// the two classes have to be separated by exactly one space and a /// block with only one of them must emit no stray space — the sort of /// thing that is obviously right in each of two places and wrong in /// the one where they meet. fn block_class( txn: &T, e: &XmlElementRef, extra: Option<&'static str>, ) -> Option { match (extra, indent_class(txn, e)) { (Some(a), Some(i)) => Some(format!("{a} {i}")), (Some(a), None) => Some(a.to_string()), (None, Some(i)) => Some(i), (None, None) => None, } } /// The class `fragment_html` puts on an aligned paragraph, from the /// same table. fn align_class(txn: &T, e: &XmlElementRef) -> Option<&'static str> { let value = e.get_attribute(txn, "align")?; ALIGNMENTS .iter() .find(|(name, _)| *name == value) .map(|(_, class)| *class) } fn text_html(txn: &T, t: &XmlTextRef, out: &mut String) { // Diff gives runs with their attributes, which is where marks live. for chunk in t.diff(txn, yrs::types::text::YChange::identity) { let mut open = Vec::new(); if let Some(attrs) = &chunk.attributes { for (name, o, c) in MARKS { if attrs.contains_key(*name) { open.push((*o, *c)); } } } for (o, _) in &open { out.push_str(o); } if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert { escape(s, out); } for (_, c) in open.iter().rev() { out.push_str(c); } } } fn node_html(txn: &T, node: &XmlOut, out: &mut String) { match node { XmlOut::Text(t) => text_html(txn, t, out), XmlOut::Element(e) => { let name = e.tag().to_string(); let (open, close) = match name.as_str() { // A paragraph can be aligned, indented, both, or // neither. Both classes are literals this binary builds // — one from the ALIGNMENTS table, one from a u8 that // has already been clamped to 1..=8 — so the joined // value can only be one of a small closed set of // strings, and nothing from the document reaches // attribute position. "paragraph" => match block_class(txn, e, align_class(txn, e)) { Some(class) => (format!("

"), "

".to_string()), None => ("

".to_string(), "

".to_string()), }, "heading" => { // Clamp: a corrupt or hostile level must not emit // or . let level = e .get_attribute(txn, "level") .and_then(|v| v.parse::().ok()) .unwrap_or(1) .clamp(1, 6); // Headings indent too — every word processor lets // them — but they do not align: alignment is on the // paragraph spec alone, here and in the editor's // schema, and adding it is a change to both. match block_class(txn, e, None) { Some(class) => ( format!(""), format!(""), ), None => (format!(""), format!("")), } } "bullet_list" => ("
    ".to_string(), "
".to_string()), "ordered_list" => ("
    ".to_string(), "
".to_string()), "list_item" => ("
  • ".to_string(), "
  • ".to_string()), "blockquote" => ("
    ".to_string(), "
    ".to_string()), // Shift+Enter's node (prosemirror-schema-basic's own // `hard_break`; the tag is the node's name because // y-prosemirror builds every non-text node as an // XmlElement named after its type). `
    ` is void, so // it has an opening and no closing markup — the loop // below still walks its children, which a real editor // never gives it and a client posting raw Yjs bytes // could, and they render after the break rather than // vanishing, per the module comment. "hard_break" => ("
    ".to_string(), String::new()), // Unknown: render children with no wrapper. See the // module comment — silence here is data loss. _ => (String::new(), String::new()), }; out.push_str(&open); for child in e.children(txn) { node_html(txn, &child, out); } out.push_str(&close); } XmlOut::Fragment(f) => { for child in f.children(txn) { node_html(txn, &child, out); } } } } /// The document rendered as HTML, escaped and ready to place inside the /// page container. pub fn fragment_html(doc: &Doc) -> String { let f = root(doc); let txn = doc.transact(); let mut out = String::new(); for child in f.children(&txn) { node_html(&txn, &child, &mut out); } out } fn node_text(txn: &T, node: &XmlOut, blocks: &mut Vec, cur: &mut String) { match node { XmlOut::Text(t) => cur.push_str(&t.get_string(txn)), XmlOut::Element(e) => { let name = e.tag().to_string(); // A hard break ends the current line and the block carries // on: one newline, where the block boundary below is a // blank line. It is deliberately NOT in the is_block set — // treating it as a block would turn one paragraph into two // wherever a person pressed Shift+Enter. if name == "hard_break" { cur.push('\n'); return; } let is_block = matches!( name.as_str(), "paragraph" | "heading" | "list_item" | "blockquote" ); // A text sibling that precedes this block with no block of // its own (unreachable through ProseMirror's own schema, // but reachable the moment a client posts arbitrary Yjs // bytes — see fragment_text's own doc comment) must not // run straight into this block's text with no separator: // flush whatever is already pending as its own segment // before this block contributes anything. if is_block && !cur.is_empty() { blocks.push(std::mem::take(cur)); } let mut had_children = false; for child in e.children(txn) { had_children = true; node_text(txn, &child, blocks, cur); } if is_block { if !cur.is_empty() { blocks.push(std::mem::take(cur)); } else if !had_children { // A structurally empty block (`

    `) still // renders as a blank paragraph in fragment_html; // mirror that here rather than silently dropping // it, which would make the two renderers disagree // about how many paragraphs a document has. A // block whose children already flushed their own // text (had_children but cur now empty, e.g. a // list_item wrapping a paragraph) must not get a // second, spurious empty entry — that is exactly // what had_children distinguishes. blocks.push(String::new()); } } } XmlOut::Fragment(f) => { for child in f.children(txn) { node_text(txn, &child, blocks, cur); } } } } /// The document as plain text, one blank line between blocks. Used by /// the list's preview and by anything that wants the words without the /// markup. /// /// Neither ProseMirror's own schema nor its `y-prosemirror` binding can /// produce a fragment with a bare text node preceding a block, or a /// structurally empty block — so, until this task, node_text's two /// edge cases here were unreachable in practice: a text sibling ahead /// of a block used to run straight into that block's own text with no /// separator (`"orphanactual"` for `[text("orphan"), paragraph(text("actual"))]`), /// and an empty `

    ` that fragment_html preserves used to vanish /// here instead of leaving a blank line. `POST /d/{sqid}/updates` /// (Task 5) is what makes them reachable: it accepts arbitrary /// client-supplied Yjs bytes, not only what a real ProseMirror editor /// would ever emit. Both are fixed in node_text above and covered by /// core/tests/fragment.rs. pub fn fragment_text(doc: &Doc) -> String { let f = root(doc); let txn = doc.transact(); let mut blocks = Vec::new(); let mut cur = String::new(); for child in f.children(&txn) { node_text(&txn, &child, &mut blocks, &mut cur); } if !cur.is_empty() { blocks.push(cur); } blocks.join("\n\n") } // --- the structured renderer --- // // `fragment_html` above answers "what does this document look like"; // `fragment_json` below answers "what is this document made of". The // export paths (`internal/export`, `internal/pdf`) need the second // question answered: where a list ends, which run is bold, which // paragraph is a heading. Re-deriving that in Go by parsing the markup // this file emits would mean a parser to keep honest against an // emitter, and Go's standard library has no HTML parser to do it with. // // The shape is ProseMirror's own JSON — `{"type":..,"attrs":..,"content":[..]}`, // text as `{"type":"text","text":..,"marks":[..]}` — because that is the // vocabulary the editor on the other side of the wire already speaks, // so a Go consumer and a future JS one describe the same document the // same way. Written by hand rather than through serde: the crate has // no serialisation dependency outside dev-dependencies, and this is // one escape function and three walks. /// JSON string escaping, per RFC 8259: the two mandatory escapes, the /// three shorthands, and `\u00XX` for everything else below U+0020. /// Everything at or above U+0020 other than `"` and `\` goes through /// as-is — the output is UTF-8 and JSON says that is legal. fn escape_json(s: &str, out: &mut String) { for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => { let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } } } /// The mark names on one text run: the ones this build knows in `MARKS` /// order, then any it does not, sorted. /// /// The sort is not cosmetic. `Attrs` is a `HashMap`, so iterating it /// yields an order that varies between runs of the same binary on the /// same document — an unsorted tail would make this renderer's output /// nondeterministic, which would make every golden-file test on the Go /// side flake rather than fail. Unknown marks are carried through /// rather than dropped for the same reason unknown *nodes* render their /// children (see the module comment): a consumer that ignores a mark it /// does not recognise still has the text, and one that grows to /// understand it needs it to have been there. /// WHOEVER ADDS THE FIRST VALUED MARK: THIS FUNCTION IS WHERE THE /// ALLOWLIST GOES, AND IT MUST NOT ASSUME ONLY THAT MARK HAS A VALUE. /// /// This returns NAMES. Every mark's value — including an unknown mark's /// — is dropped here and reaches no consumer, which is why an unknown /// mark carried through by name is safe today: `fragment_html` finds no /// MARKS row and emits no wrapper, and `text_json` writes the name /// through `escape_json`. /// /// A valued mark changes that. The moment one value is allowed past /// this point, the shape of the question becomes "which marks may carry /// a value, and what may that value be" — and the tempting answer, that /// `link` is the one with an `href`, is wrong on arrival. This function /// collects marks it has never heard of, straight off the wire, and /// `POST /d/{sqid}/updates` accepts bytes that never went through /// ProseMirror's schema. So an unknown mark can carry an arbitrary /// value too, and an allowlist written around the known one would let /// it past. /// /// It is also the right place, and that is the other half of the note: /// this is the single point the HTML renderer and every JSON consumer /// agree on what marks a run carries, so a check here cannot be /// re-implemented differently by `markdown.go` and `pdf.go` the way one /// in `node_html` would have to be. Plan 7's D1 names it. /// /// (Found by the session that owns Plan 7, reading this file after the /// point-ladder work restructured around it — recorded here rather than /// only in that plan, because the plan may land long after someone else /// touches this function.) fn mark_names(attrs: &yrs::types::Attrs) -> Vec { let mut names: Vec = MARKS .iter() .filter(|(name, _, _)| attrs.contains_key(*name)) .map(|(name, _, _)| (*name).to_string()) .collect(); let mut unknown: Vec = attrs .keys() .map(|k| k.to_string()) .filter(|k| !MARKS.iter().any(|(name, _, _)| *name == k.as_str())) .collect(); unknown.sort(); names.append(&mut unknown); names } fn text_json(txn: &T, t: &XmlTextRef, out: &mut String, first: &mut bool) { for chunk in t.diff(txn, yrs::types::text::YChange::identity) { let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert else { // A non-string insert is an embed. `fragment_html` renders // nothing for one today, and inventing a JSON shape for // something with no renderer would be guessing at a schema // rather than reporting one. continue; }; if s.is_empty() { continue; } if !*first { out.push(','); } *first = false; out.push_str("{\"type\":\"text\",\"text\":\""); escape_json(s, out); out.push('"'); if let Some(attrs) = &chunk.attributes { let names = mark_names(attrs); if !names.is_empty() { out.push_str(",\"marks\":["); for (i, name) in names.iter().enumerate() { if i > 0 { out.push(','); } out.push_str("{\"type\":\""); escape_json(name, out); out.push_str("\"}"); } out.push(']'); } } out.push('}'); } } // Unlike the two renderers above, this one needs no case of its own // for `hard_break`: an element with no children already comes out as // `{"type":"hard_break","content":[]}`, which names the node and is // what internal/export reads. The empty `content` is how this renderer // spells every childless element (an empty paragraph included), so a // leaf is not a special shape here. fn node_json(txn: &T, node: &XmlOut, out: &mut String, first: &mut bool) { match node { XmlOut::Text(t) => text_json(txn, t, out, first), XmlOut::Element(e) => { let name = e.tag().to_string(); if !*first { out.push(','); } *first = false; out.push_str("{\"type\":\""); escape_json(&name, out); out.push('"'); // Attributes are collected and written ONCE, as a single // object. // // This used to be two independent `if name == …` arms, each // writing a complete `,"attrs":{…}`. That was correct only // for as long as no node could carry two attributes: a // heading has a level and a paragraph has an alignment, and // never both. Indent broke that — a paragraph can be // centred AND indented, and a heading can be indented too — // and the old shape would have emitted `"attrs"` twice in // one object. Every JSON parser accepts that and they do not // agree on which wins, so it would not have failed loudly; // internal/export would have quietly lost one of the two. // // Each value below is a number or a literal from a closed // table in this binary, never a byte from the document, so // nothing here needs escaping — the same property // `fragment_html` depends on, held for the same reason. let mut attrs: Vec = Vec::new(); if name == "heading" { // Clamped exactly as `node_html` clamps it, and for the // same reason: a corrupt or hostile level must not reach // a consumer as `h0` or `h99`. Both renderers clamping // independently is deliberate — a document must not // paginate as one level on screen and export as another. let level = e .get_attribute(txn, "level") .and_then(|v| v.parse::().ok()) .unwrap_or(1) .clamp(1, 6); attrs.push(format!("\"level\":{level}")); } if name == "paragraph" { // Same allowlist, same drop, and — like the heading // clamp above — arrived at independently in both // renderers, so a paragraph cannot read as centred on // screen and export flush left. `align` is the // allowlist's own key, a literal in this binary, so // nothing here needs escaping and nothing from the // document can appear. if let Some(align) = alignment(txn, e) { attrs.push(format!("\"align\":\"{align}\"")); } } if name == "paragraph" || name == "heading" { if let Some(indent) = indent_level(txn, e) { attrs.push(format!("\"indent\":{indent}")); } } if !attrs.is_empty() { let _ = write!(out, ",\"attrs\":{{{}}}", attrs.join(",")); } out.push_str(",\"content\":["); let mut inner = true; for child in e.children(txn) { node_json(txn, &child, out, &mut inner); } out.push_str("]}"); } XmlOut::Fragment(f) => { // Flattened into the parent's content, exactly as // `node_html` renders a nested fragment's children with no // wrapper of its own. for child in f.children(txn) { node_json(txn, &child, out, first); } } } } /// The document as ProseMirror-shaped JSON: `{"type":"doc","content":[..]}`. /// /// Deterministic for a given document state — see `mark_names` for the /// one place that took work — so a Go consumer's golden files are /// stable. pub fn fragment_json(doc: &Doc) -> String { let f = root(doc); let txn = doc.transact(); let mut out = String::from("{\"type\":\"doc\",\"content\":["); let mut first = true; for child in f.children(&txn) { node_json(&txn, &child, &mut out, &mut first); } out.push_str("]}"); out }