oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev 66d8a694da26a7baa4a80fc188371ab73e704a34 parent 79d6a75
8 files changed, +304 −0
  • core/src/fragment.rs +156 −0
  • core/src/lib.rs +2 −0
  • core/tests/fragment.rs +89 −0
  • fixtures/generate-fragment.mjs +57 −0
  • fixtures/vectors/fragment/marks.bin binary
  • fixtures/vectors/fragment/nested.bin binary
  • fixtures/vectors/fragment/simple.bin binary
  • internal/core/testdata/docs_core.wasm binary
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("&amp;"),
+ '<' => out.push_str("&lt;"),
+ '>' => out.push_str("&gt;"),
+ '"' => out.push_str("&quot;"),
+ '\'' => out.push_str("&#39;"),
+ _ => 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("&lt;script&gt;"), "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