core: render a ProseMirror XmlFragment to text and HTML
Adds fragment_text/fragment_html, reading the "prosemirror" Y.XmlFragment that y-prosemirror writes (alongside the existing "content" Y.Text reader, unchanged). Needed so the scriptless read view can render documents edited in the browser once the editor lands. Includes fixtures/generate-fragment.mjs and its committed vectors, and re-copies internal/core/testdata/docs_core.wasm to match the rebuilt artifact, as the gate requires.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
8 files changed,
+304
−0
core/src/fragment.rs+156 −0core/src/lib.rs+2 −0core/tests/fragment.rs+89 −0fixtures/generate-fragment.mjs+57 −0fixtures/vectors/fragment/marks.binbinaryfixtures/vectors/fragment/nested.binbinaryfixtures/vectors/fragment/simple.binbinaryinternal/core/testdata/docs_core.wasmbinary
Intent
diff --git a/core/src/fragment.rs b/core/src/fragment.rs| new file mode 100644 |
| index 0000000..34c0021 |
| --- /dev/null |
| +++ b/core/src/fragment.rs |
| @@ -0,0 +1,156 @@ |
| +//! 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, 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. |
| +/// A mark not listed here contributes no wrapper but never suppresses |
| +/// its text. |
| +const MARKS: &[(&str, &str)] = &[("strong", "strong"), ("em", "em"), ("code", "code")]; |
| + |
| +fn text_html<T: ReadTxn>(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, tag) in MARKS { |
| + if attrs.contains_key(*name) { |
| + open.push(*tag); |
| + } |
| + } |
| + } |
| + for tag in &open { |
| + let _ = write!(out, "<{tag}>"); |
| + } |
| + if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert { |
| + escape(s, out); |
| + } |
| + for tag in open.iter().rev() { |
| + let _ = write!(out, "</{tag}>"); |
| + } |
| + } |
| +} |
| + |
| +fn node_html<T: ReadTxn>(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() { |
| + "paragraph" => ("<p>".to_string(), "</p>".to_string()), |
| + "heading" => { |
| + // Clamp: a corrupt or hostile level must not emit |
| + // <h0> or <h99>. |
| + let level = e |
| + .get_attribute(txn, "level") |
| + .and_then(|v| v.parse::<u8>().ok()) |
| + .unwrap_or(1) |
| + .clamp(1, 6); |
| + (format!("<h{level}>"), format!("</h{level}>")) |
| + } |
| + "bullet_list" => ("<ul>".to_string(), "</ul>".to_string()), |
| + "ordered_list" => ("<ol>".to_string(), "</ol>".to_string()), |
| + "list_item" => ("<li>".to_string(), "</li>".to_string()), |
| + "blockquote" => ("<blockquote>".to_string(), "</blockquote>".to_string()), |
| + // 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<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) { |
| + match node { |
| + XmlOut::Text(t) => cur.push_str(&t.get_string(txn)), |
| + XmlOut::Element(e) => { |
| + let name = e.tag().to_string(); |
| + let is_block = matches!( |
| + name.as_str(), |
| + "paragraph" | "heading" | "list_item" | "blockquote" |
| + ); |
| + for child in e.children(txn) { |
| + node_text(txn, &child, blocks, cur); |
| + } |
| + if is_block && !cur.is_empty() { |
| + blocks.push(std::mem::take(cur)); |
| + } |
| + } |
| + 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. |
| +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") |
| +} |
diff --git a/core/src/lib.rs b/core/src/lib.rs| index 1d9969e..73f5c0d 100644 |
| --- a/core/src/lib.rs |
| +++ b/core/src/lib.rs |
| @@ -54,6 +54,8 @@ |
| //! above) rather than relying on `fastrand` to fail loudly when it can't — |
| //! it won't. |
| pub mod abi; |
| +mod fragment; |
| +pub use fragment::{fragment_html, fragment_text, ROOT as FRAGMENT_ROOT}; |
| #[cfg(target_arch = "wasm32")] |
| #[no_mangle] |
diff --git a/core/tests/fragment.rs b/core/tests/fragment.rs| new file mode 100644 |
| index 0000000..6403166 |
| --- /dev/null |
| +++ b/core/tests/fragment.rs |
| @@ -0,0 +1,89 @@ |
| +use docs_core::{fragment_html, fragment_text}; |
| +use yrs::updates::decoder::Decode; |
| +use yrs::{Doc, Options, Transact, Update, XmlFragment}; |
| + |
| +fn doc_from(bytes: &[u8]) -> Doc { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("fixture".into(), 1)); |
| + let update = Update::decode_v1(bytes).expect("decode fixture"); |
| + doc.transact_mut() |
| + .apply_update(update) |
| + .expect("apply fixture"); |
| + doc |
| +} |
| + |
| +#[test] |
| +fn reads_paragraphs_as_blocks() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin")); |
| + assert_eq!(fragment_text(&doc), "First paragraph.\n\nSecond paragraph."); |
| +} |
| + |
| +#[test] |
| +fn renders_paragraphs_as_html() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin")); |
| + assert_eq!( |
| + fragment_html(&doc), |
| + "<p>First paragraph.</p><p>Second paragraph.</p>" |
| + ); |
| +} |
| + |
| +#[test] |
| +fn renders_marks() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/marks.bin")); |
| + let html = fragment_html(&doc); |
| + assert!(html.contains("<strong>bold</strong>"), "got {html}"); |
| + assert!(html.contains("<em>italic</em>"), "got {html}"); |
| +} |
| + |
| +#[test] |
| +fn renders_headings_and_lists() { |
| + let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin")); |
| + let html = fragment_html(&doc); |
| + assert_eq!( |
| + html, |
| + "<h2>A heading</h2><ul><li><p>One</p></li><li><p>Two</p></li></ul>" |
| + ); |
| +} |
| + |
| +// The read view puts this straight into a page. A document whose text |
| +// contains markup must not be able to inject it. |
| +#[test] |
| +fn escapes_html_in_text() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("esc".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph")); |
| + p.push_back( |
| + &mut txn, |
| + yrs::XmlTextPrelim::new("<script>alert(1)</script>"), |
| + ); |
| + } |
| + let html = fragment_html(&doc); |
| + assert!(!html.contains("<script>"), "unescaped script tag: {html}"); |
| + assert!(html.contains("<script>"), "not escaped: {html}"); |
| +} |
| + |
| +// An empty document is an empty canvas, not an error (show.html's own |
| +// comment says so). |
| +#[test] |
| +fn empty_document_is_empty_output() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1)); |
| + assert_eq!(fragment_text(&doc), ""); |
| + assert_eq!(fragment_html(&doc), ""); |
| +} |
| + |
| +// An unknown node name must not vanish silently and must not panic — |
| +// a future editor version can introduce nodes this build has never |
| +// seen, and dropping their text would be data loss in the read view. |
| +#[test] |
| +fn unknown_nodes_keep_their_text() { |
| + let doc = Doc::with_options(Options::with_guid_and_client_id("unk".into(), 1)); |
| + { |
| + let f = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let mut txn = doc.transact_mut(); |
| + let n = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("call_to_action")); |
| + n.push_back(&mut txn, yrs::XmlTextPrelim::new("keep me")); |
| + } |
| + assert!(fragment_text(&doc).contains("keep me")); |
| + assert!(fragment_html(&doc).contains("keep me")); |
| +} |
diff --git a/fixtures/generate-fragment.mjs b/fixtures/generate-fragment.mjs| new file mode 100644 |
| index 0000000..3ddd59b |
| --- /dev/null |
| +++ b/fixtures/generate-fragment.mjs |
| @@ -0,0 +1,57 @@ |
| +// Generates the ProseMirror-shaped Y.XmlFragment fixtures that define the |
| +// wire contract for core/src/fragment.rs. These are the contract: never |
| +// edit a fixture to make a test pass. A deliberate rule change regenerates |
| +// them in the same commit. |
| +import * as Y from 'yjs' |
| +import { writeFileSync, mkdirSync } from 'node:fs' |
| + |
| +// The client id is pinned so the bytes are reproducible. Options:: |
| +// with_guid_and_client_id on the Rust side pins the same pair; nothing |
| +// in this pipeline may draw entropy (see the plan's Global Constraints). |
| +function docWith(build) { |
| + const doc = new Y.Doc() |
| + doc.clientID = 1 |
| + build(doc.getXmlFragment('prosemirror')) |
| + return Y.encodeStateAsUpdate(doc) |
| +} |
| + |
| +function el(name, attrs, ...children) { |
| + const e = new Y.XmlElement(name) |
| + for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, v) |
| + if (children.length) e.insert(0, children) |
| + return e |
| +} |
| + |
| +function text(s, marks) { |
| + const t = new Y.XmlText() |
| + t.insert(0, s, marks ?? undefined) |
| + return t |
| +} |
| + |
| +mkdirSync('vectors/fragment', { recursive: true }) |
| + |
| +writeFileSync('vectors/fragment/simple.bin', docWith(f => { |
| + f.insert(0, [ |
| + el('paragraph', null, text('First paragraph.')), |
| + el('paragraph', null, text('Second paragraph.')), |
| + ]) |
| +})) |
| + |
| +writeFileSync('vectors/fragment/marks.bin', docWith(f => { |
| + f.insert(0, [ |
| + el('paragraph', null, |
| + text('Plain then '), text('bold', { strong: {} }), |
| + text(' then '), text('italic', { em: {} }), text('.')), |
| + ]) |
| +})) |
| + |
| +writeFileSync('vectors/fragment/nested.bin', docWith(f => { |
| + f.insert(0, [ |
| + el('heading', { level: '2' }, text('A heading')), |
| + el('bullet_list', null, |
| + el('list_item', null, el('paragraph', null, text('One'))), |
| + el('list_item', null, el('paragraph', null, text('Two')))), |
| + ]) |
| +})) |
| + |
| +console.log('wrote 3 fragment vectors') |
diff --git a/fixtures/vectors/fragment/marks.bin b/fixtures/vectors/fragment/marks.bin| new file mode 100644 |
| index 0000000..ad631e5 |
| Binary files /dev/null and b/fixtures/vectors/fragment/marks.bin differ |
diff --git a/fixtures/vectors/fragment/nested.bin b/fixtures/vectors/fragment/nested.bin| new file mode 100644 |
| index 0000000..aa3ae10 |
| Binary files /dev/null and b/fixtures/vectors/fragment/nested.bin differ |
diff --git a/fixtures/vectors/fragment/simple.bin b/fixtures/vectors/fragment/simple.bin| new file mode 100644 |
| index 0000000..ae20157 |
| Binary files /dev/null and b/fixtures/vectors/fragment/simple.bin differ |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| index 1048e3e..ca533da 100755 |
| Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ |