Plan 4: correct the yrs API surface against 0.23.5's own source
XmlOut, not XmlNode; get_attribute already returns Option<String>. Verified in the registry source rather than recalled.
1 file changed,
+21
−11
docs/superpowers/plans/2026-08-31-editing.md+21 −11
diff --git a/docs/superpowers/plans/2026-08-31-editing.md b/docs/superpowers/plans/2026-08-31-editing.md| index 495df33..fc52858 100644 |
| --- a/docs/superpowers/plans/2026-08-31-editing.md |
| +++ b/docs/superpowers/plans/2026-08-31-editing.md |
| @@ -259,7 +259,7 @@ Walk the fragment recursively. Two rules matter and both are pinned by tests abo |
| //! loss, not a degradation. |
| use std::fmt::Write as _; |
| -use yrs::types::xml::{XmlFragment, XmlNode, XmlTextRef}; |
| +use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef}; |
| use yrs::{Doc, GetString, ReadTxn, Transact, Xml, XmlFragmentRef}; |
| /// The CRDT root name. y-prosemirror's default, and part of the wire |
| @@ -311,10 +311,10 @@ fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) { |
| } |
| } |
| -fn node_html<T: ReadTxn>(txn: &T, node: &XmlNode, out: &mut String) { |
| +fn node_html<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String) { |
| match node { |
| - XmlNode::Text(t) => text_html(txn, t, out), |
| - XmlNode::Element(e) => { |
| + 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()), |
| @@ -323,7 +323,7 @@ fn node_html<T: ReadTxn>(txn: &T, node: &XmlNode, out: &mut String) { |
| // <h0> or <h99>. |
| let level = e |
| .get_attribute(txn, "level") |
| - .and_then(|v| v.to_string().parse::<u8>().ok()) |
| + .and_then(|v| v.parse::<u8>().ok()) |
| .unwrap_or(1) |
| .clamp(1, 6); |
| (format!("<h{level}>"), format!("</h{level}>")) |
| @@ -342,7 +342,7 @@ fn node_html<T: ReadTxn>(txn: &T, node: &XmlNode, out: &mut String) { |
| } |
| out.push_str(&close); |
| } |
| - XmlNode::Fragment(f) => { |
| + XmlOut::Fragment(f) => { |
| for child in f.children(txn) { |
| node_html(txn, &child, out); |
| } |
| @@ -362,10 +362,10 @@ pub fn fragment_html(doc: &Doc) -> String { |
| out |
| } |
| -fn node_text<T: ReadTxn>(txn: &T, node: &XmlNode, blocks: &mut Vec<String>, cur: &mut String) { |
| +fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) { |
| match node { |
| - XmlNode::Text(t) => cur.push_str(&t.get_string(txn)), |
| - XmlNode::Element(e) => { |
| + 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(), |
| @@ -378,7 +378,7 @@ fn node_text<T: ReadTxn>(txn: &T, node: &XmlNode, blocks: &mut Vec<String>, cur: |
| blocks.push(std::mem::take(cur)); |
| } |
| } |
| - XmlNode::Fragment(f) => { |
| + XmlOut::Fragment(f) => { |
| for child in f.children(txn) { |
| node_text(txn, &child, blocks, cur); |
| } |
| @@ -1134,4 +1134,14 @@ git commit -m "app: edit a document — ProseMirror bound to the shared fragment |
| **Type consistency.** `fragment_text`/`fragment_html` (Rust) → `docs_core_html_len`/`docs_core_html_read` (ABI) → `(*Handle).HTML` (Go host) → `(*Store).HTML` returning `template.HTML` (app) → `{{.HTML}}` (template). The CRDT root is `"prosemirror"` in `fragment.rs`, `generate-fragment.mjs`, `generate-seed.mjs` and `editor.js`. `maxUpdateBytes` and `maxLogBytes` are defined in Task 5 and referenced only there. |
| -**Risk to watch.** Task 1's `yrs` 0.23.5 API surface for `diff`, `children` and `get_attribute` may differ from what is written above. The assertions are the contract; adapt the call sites, not the tests. |
| +**API surface, verified against `yrs` 0.23.5's own source before this plan was committed** (`~/.cargo/registry/src/*/yrs-0.23.5`), because writing Rust against a half-remembered API is how a plan wastes an implementer's afternoon: |
| + |
| +- `Doc::get_or_insert_xml_fragment(name) -> XmlFragmentRef` — exists, `doc.rs:253`. |
| +- The child enum is **`XmlOut`**, not `XmlNode` — `types/xml.rs:89`, variants `Element(XmlElementRef)`, `Fragment(XmlFragmentRef)`, `Text(XmlTextRef)`. The code above uses `XmlOut`. |
| +- `children(txn)` yields `XmlOut` — `types/xml.rs:1223`. |
| +- `XmlElementRef::tag() -> &Arc<str>` — `types/xml.rs:280`. |
| +- `get_attribute(txn, name) -> Option<String>` — `types/xml.rs:1026`. It returns the `String` directly, so the heading level parses with `.and_then(|v| v.parse::<u8>().ok())` and no intermediate `to_string()`. |
| + |
| +**Still unverified, and the one thing to check first.** `XmlTextRef::diff`'s exact signature and the shape of its returned chunks — this is where marks come from, and `MARKS` handling in `text_html` depends on it. Read `types/xml.rs` and `types/text.rs` before writing that function. If `diff` proves awkward, the fallback is `get_string` plus a separate attribute walk, which loses mark fidelity — take that only after reading, and say so in your report, because the marks test is not optional. |
| + |
| +The assertions in Step 2 are the contract; adapt call sites, never the tests. |