oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# Editing a Document Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Open a document, type into it, reload the page, and the text is still there — with the scriptless read view still rendering the same content server-side.
6
7**Architecture:** The document's CRDT root changes from a `Y.Text` named `"content"` to a `Y.XmlFragment` named `"prosemirror"`, which is the shape `y-prosemirror` requires. The Rust core learns to walk that fragment and render it to plain text and to HTML, so the server keeps rendering the read view without running JavaScript. The browser loads a document by fetching its raw update log and replaying it through JS `yjs`; edits produce new Yjs updates which are POSTed back, validated through the same Rust core, and appended to `doc_updates`. Transport is plain HTTP for this plan — the WebSocket belongs with live collaboration.
8
9**Tech Stack:** Rust `yrs` 0.23.5 in WebAssembly (wazero host, no cgo), Go, JS `yjs` 13.6.27, `prosemirror-*` and `y-prosemirror`, vendored as prebuilt ESM bundles.
10
11**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — particularly §0 (one wire format, two implementations), §5.2 (the sandbox is load-bearing), §6.1–6.5 (pages, the scriptless path, ProseMirror), §7.1 (merge and transport).
12
13---
14
15## Sequencing decisions made when writing this plan
16
17These resolve things the spec settles for the *product* but not for *this plan*. They are decisions, not open questions — an implementer should follow them, and a reviewer should hold the work to them.
18
19**D1 — Transport is plain HTTP, not a WebSocket.** §7.1 specifies a WebSocket with an SSE fallback. That is the transport for *live collaboration between people*. This plan delivers one person editing one document with durable persistence, for which a POST is sufficient and a socket is a liability: §7.3's hibernation problem exists precisely because Docs holds a socket per open editor. Live collaboration, presence, and the socket's idle timeout arrive together in a later plan, and the update-log format this plan establishes is what that plan will stream.
20
21**D2 — Loading sends the whole update log.** No new state-export ABI is needed: the server already stores raw Yjs update bytes, and the browser's `yjs` can apply them directly. §4.1's snapshot cadence and compaction horizon exist to bound this and are not in this plan. This is honest but O(n) in edits, so Task 6 caps the response and Task 6's test pins the cap.
22
23**D3 — No pagination in this plan.** §6.1 says editing is paginated and a pageless mode is not in the first slice. That is a statement about the shipped product, not about plan order. `internal/docs/views/show.html`'s own comment already sequences it: *"pagination arrives with a later plan's layout engine, and this shape … is what lets that land without restructuring this markup."* Keep that shape. Do not add page-break logic.
24
25**D4 — No toolbar in this plan.** §6.3's toolbar and inspector are a later plan. ProseMirror's `baseKeymap` plus `prosemirror-keymap` give working keyboard editing, including bold and italic, which is enough to satisfy "edit a document" and to exercise the merge path with marks.
26
27**D5 — The existing `Y.Text` wire fixtures stay.** They test the *update encoding* — that Rust and JS agree on bytes — which is §0's actual contract and is independent of which shared type an application puts in the document. Task 1 adds fragment fixtures alongside them. Deleting the text fixtures would remove wire coverage and replace it with nothing.
28
29## Global Constraints
30
31Every task's requirements implicitly include this section.
32
33- `CGO_ENABLED=0`. One static Go binary. The WebAssembly runtime is wazero, pure Go.
34- The core module must have **zero host imports**. `core/tests/no_host_imports.rs` asserts this; `DOCS_REQUIRE_WASM=1` makes a missing artifact a failure rather than a skip.
35- **No entropy from the CRDT library.** `Doc::new`, `Options::with_client_id` and friends are banned by clippy `disallowed-methods`. Construct documents with `Options::with_guid_and_client_id`. A new call site that needs a `Doc` must take both a guid and a client id from its caller.
36- **Document text exists only as replayed Yjs updates.** There is no content column and nothing may add one.
37- Markup uses the bare `rst-*` **attribute** grammar, never `class="rst-*"`. This app's own vocabulary is `docs-*` (see `show.html`'s comment), also as attributes.
38- Every user-visible string is a catalog key in `internal/docs/render.go`, never a literal in a template.
39- Never call `sessions.UserID`. An AST guard in `internal/lint` fails the build, including through aliased and dot imports. Use `auth.From(r).Address`.
40- Never use `scope.Owned`. It fails as a short list rather than an error.
41- `Store.ApplyUpdate` re-derives ownership from the database before any write. Do not weaken or bypass that check.
42- The fixtures in `fixtures/` are the contract, not an output. `.amadan/ci.d/10-gate` fails if they change.
43- Run `.amadan/ci.d/10-gate` before reporting any task complete. Exit 0 is green; exit 2 (pending) and exit 3 (nothing ran) are not.
44
45## File Structure
46
47| File | Responsibility |
48|---|---|
49| `core/src/fragment.rs` *(new)* | Walk a `Y.XmlFragment` and render it to plain text and to HTML. The only place that knows the ProseMirror node vocabulary on the Rust side. |
50| `core/src/lib.rs` | Gains `fragment_text` / `fragment_html` entry points delegating to `fragment.rs`. Keeps `text_content` for the existing wire fixtures. |
51| `core/src/abi.rs` | Gains `docs_core_html_len` / `docs_core_html_read`. ABI version 1 → 2. |
52| `internal/core/core.go` | `abiVersion` 1 → 2; `(*Handle).HTML(ctx)`. |
53| `fixtures/generate-fragment.mjs` *(new)* | Builds `Y.XmlFragment` fixtures with a pinned client id, for the Rust side to reproduce. |
54| `fixtures/vectors/fragment/*` *(new)* | The fragment fixtures themselves. Contract, not output. |
55| `internal/docs/seeddata/*.update.bin` | Regenerated as fragment updates. |
56| `fixtures/generate-seed.mjs` | Emits ProseMirror-shaped fragments instead of `Y.Text`. |
57| `internal/docs/views/show.html` | Renders server-side HTML from the fragment; hosts the editor mount point. |
58| `static/` *(new)* | Vendored prebuilt ESM bundles: `editor.<hash>.js`. |
59| `fixtures/build-editor.mjs` *(new)* | esbuild script producing `static/editor.js`. Run by a human, output committed. |
60| `internal/docs/assets.go` *(new)* | Embeds and serves `static/`, with a staleness check mirroring the wasm one. |
61| `internal/docs/handlers.go` | `GET /d/{sqid}/updates`, `POST /d/{sqid}/updates`. |
62| `internal/docs/editor.js` *(new, under static/)* | Binds ProseMirror to the Yjs fragment, loads the log, posts updates. |
63
64---
65
66### Task 1: The Rust core reads a ProseMirror fragment
67
68The core can currently only read a `Y.Text` named `"content"`. `y-prosemirror` stores its document in a `Y.XmlFragment` named `"prosemirror"`, whose children are `Y.XmlElement` nodes (`paragraph`, `heading`, `bullet_list`, `list_item`) containing `Y.XmlText` runs that carry marks as attributes. Without this, a document edited in the browser reads back as empty text, and the scriptless read view (§6.2) silently shows nothing.
69
70**Files:**
71- Create: `core/src/fragment.rs`
72- Create: `fixtures/generate-fragment.mjs`
73- Create: `fixtures/vectors/fragment/simple.bin`, `fixtures/vectors/fragment/marks.bin`, `fixtures/vectors/fragment/nested.bin` (generated, then committed)
74- Modify: `core/src/lib.rs`
75- Test: `core/tests/fragment.rs`
76
77**Interfaces:**
78- Consumes: `yrs::Doc`, built via `Options::with_guid_and_client_id` — never `Doc::new`.
79- Produces:
80 - `pub fn fragment_text(doc: &Doc) -> String` — block-separated plain text.
81 - `pub fn fragment_html(doc: &Doc) -> String` — escaped HTML.
82 - The CRDT root name `"prosemirror"`, part of the wire contract.
83
84- [ ] **Step 1: Write the fixture generator**
85
86`fixtures/generate-fragment.mjs`. Mirror `generate.mjs`'s structure — same pinned `clientID`, same "the JS side is the contract" stance.
87
88```js
89import * as Y from 'yjs'
90import { writeFileSync, mkdirSync } from 'node:fs'
91
92// The client id is pinned so the bytes are reproducible. Options::
93// with_guid_and_client_id on the Rust side pins the same pair; nothing
94// in this pipeline may draw entropy (see the plan's Global Constraints).
95function docWith(build) {
96 const doc = new Y.Doc()
97 doc.clientID = 1
98 build(doc.getXmlFragment('prosemirror'))
99 return Y.encodeStateAsUpdate(doc)
100}
101
102function el(name, attrs, ...children) {
103 const e = new Y.XmlElement(name)
104 for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, v)
105 if (children.length) e.insert(0, children)
106 return e
107}
108
109function text(s, marks) {
110 const t = new Y.XmlText()
111 t.insert(0, s, marks ?? undefined)
112 return t
113}
114
115mkdirSync('vectors/fragment', { recursive: true })
116
117writeFileSync('vectors/fragment/simple.bin', docWith(f => {
118 f.insert(0, [
119 el('paragraph', null, text('First paragraph.')),
120 el('paragraph', null, text('Second paragraph.')),
121 ])
122}))
123
124writeFileSync('vectors/fragment/marks.bin', docWith(f => {
125 f.insert(0, [
126 el('paragraph', null,
127 text('Plain then '), text('bold', { strong: {} }),
128 text(' then '), text('italic', { em: {} }), text('.')),
129 ])
130}))
131
132writeFileSync('vectors/fragment/nested.bin', docWith(f => {
133 f.insert(0, [
134 el('heading', { level: '2' }, text('A heading')),
135 el('bullet_list', null,
136 el('list_item', null, el('paragraph', null, text('One'))),
137 el('list_item', null, el('paragraph', null, text('Two')))),
138 ])
139}))
140
141console.log('wrote 3 fragment vectors')
142```
143
144Run it once from `fixtures/`: `node generate-fragment.mjs`. Commit the `.bin` files.
145
146- [ ] **Step 2: Write the failing test**
147
148`core/tests/fragment.rs`:
149
150```rust
151use docs_core::{fragment_html, fragment_text};
152use yrs::{Doc, Options, ReadTxn, Transact, Update};
153
154fn doc_from(bytes: &[u8]) -> Doc {
155 let doc = Doc::with_options(Options::with_guid_and_client_id("fixture".into(), 1));
156 let update = Update::decode_v1(bytes).expect("decode fixture");
157 doc.transact_mut().apply_update(update).expect("apply fixture");
158 doc
159}
160
161#[test]
162fn reads_paragraphs_as_blocks() {
163 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
164 assert_eq!(fragment_text(&doc), "First paragraph.\n\nSecond paragraph.");
165}
166
167#[test]
168fn renders_paragraphs_as_html() {
169 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
170 assert_eq!(
171 fragment_html(&doc),
172 "<p>First paragraph.</p><p>Second paragraph.</p>"
173 );
174}
175
176#[test]
177fn renders_marks() {
178 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/marks.bin"));
179 let html = fragment_html(&doc);
180 assert!(html.contains("<strong>bold</strong>"), "got {html}");
181 assert!(html.contains("<em>italic</em>"), "got {html}");
182}
183
184#[test]
185fn renders_headings_and_lists() {
186 let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin"));
187 let html = fragment_html(&doc);
188 assert_eq!(
189 html,
190 "<h2>A heading</h2><ul><li><p>One</p></li><li><p>Two</p></li></ul>"
191 );
192}
193
194// The read view puts this straight into a page. A document whose text
195// contains markup must not be able to inject it.
196#[test]
197fn escapes_html_in_text() {
198 let doc = Doc::with_options(Options::with_guid_and_client_id("esc".into(), 1));
199 {
200 let f = doc.get_or_insert_xml_fragment("prosemirror");
201 let mut txn = doc.transact_mut();
202 let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
203 p.push_back(&mut txn, yrs::XmlTextPrelim::new("<script>alert(1)</script>"));
204 }
205 let html = fragment_html(&doc);
206 assert!(!html.contains("<script>"), "unescaped script tag: {html}");
207 assert!(html.contains("&lt;script&gt;"), "not escaped: {html}");
208}
209
210// An empty document is an empty canvas, not an error (show.html's own
211// comment says so).
212#[test]
213fn empty_document_is_empty_output() {
214 let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1));
215 assert_eq!(fragment_text(&doc), "");
216 assert_eq!(fragment_html(&doc), "");
217}
218
219// An unknown node name must not vanish silently and must not panic —
220// a future editor version can introduce nodes this build has never
221// seen, and dropping their text would be data loss in the read view.
222#[test]
223fn unknown_nodes_keep_their_text() {
224 let doc = Doc::with_options(Options::with_guid_and_client_id("unk".into(), 1));
225 {
226 let f = doc.get_or_insert_xml_fragment("prosemirror");
227 let mut txn = doc.transact_mut();
228 let n = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("call_to_action"));
229 n.push_back(&mut txn, yrs::XmlTextPrelim::new("keep me"));
230 }
231 assert!(fragment_text(&doc).contains("keep me"));
232 assert!(fragment_html(&doc).contains("keep me"));
233}
234```
235
236- [ ] **Step 3: Run it and watch it fail**
237
238```bash
239cd core && cargo test --test fragment
240```
241
242Expected: fails to compile — `fragment_text` and `fragment_html` do not exist.
243
244- [ ] **Step 4: Implement `core/src/fragment.rs`**
245
246Walk the fragment recursively. Two rules matter and both are pinned by tests above: escape all text, and never drop an unknown node's content.
247
248```rust
249//! Rendering a ProseMirror-shaped Y.XmlFragment to text and HTML.
250//!
251//! This is the only place on the Rust side that knows ProseMirror's
252//! node vocabulary. It exists because the read view is server-rendered
253//! (spec §6.2: reading a document works with no script), so the server
254//! must be able to render what the browser's editor wrote.
255//!
256//! Unknown nodes render their children rather than disappearing. A
257//! future editor build can introduce a node this binary has never
258//! heard of, and a reader silently losing that text would be data
259//! loss, not a degradation.
260
261use std::fmt::Write as _;
262use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef};
263use yrs::{Doc, GetString, ReadTxn, Transact, Xml, XmlFragmentRef};
264
265/// The CRDT root name. y-prosemirror's default, and part of the wire
266/// contract — changing it silently orphans every existing document.
267pub const ROOT: &str = "prosemirror";
268
269fn root(doc: &Doc) -> XmlFragmentRef {
270 doc.get_or_insert_xml_fragment(ROOT)
271}
272
273fn escape(s: &str, out: &mut String) {
274 for c in s.chars() {
275 match c {
276 '&' => out.push_str("&amp;"),
277 '<' => out.push_str("&lt;"),
278 '>' => out.push_str("&gt;"),
279 '"' => out.push_str("&quot;"),
280 '\'' => out.push_str("&#39;"),
281 _ => out.push(c),
282 }
283 }
284}
285
286/// Marks this build knows, in a fixed order so output is deterministic.
287/// A mark not listed here contributes no wrapper but never suppresses
288/// its text.
289const MARKS: &[(&str, &str)] = &[("strong", "strong"), ("em", "em"), ("code", "code")];
290
291fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
292 // Diff gives runs with their attributes, which is where marks live.
293 for chunk in t.diff(txn, yrs::types::text::YChange::identity) {
294 let mut open = Vec::new();
295 if let Some(attrs) = &chunk.attributes {
296 for (name, tag) in MARKS {
297 if attrs.contains_key(*name) {
298 open.push(*tag);
299 }
300 }
301 }
302 for tag in &open {
303 let _ = write!(out, "<{tag}>");
304 }
305 if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert {
306 escape(s, out);
307 }
308 for tag in open.iter().rev() {
309 let _ = write!(out, "</{tag}>");
310 }
311 }
312}
313
314fn node_html<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String) {
315 match node {
316 XmlOut::Text(t) => text_html(txn, t, out),
317 XmlOut::Element(e) => {
318 let name = e.tag().to_string();
319 let (open, close) = match name.as_str() {
320 "paragraph" => ("<p>".to_string(), "</p>".to_string()),
321 "heading" => {
322 // Clamp: a corrupt or hostile level must not emit
323 // <h0> or <h99>.
324 let level = e
325 .get_attribute(txn, "level")
326 .and_then(|v| v.parse::<u8>().ok())
327 .unwrap_or(1)
328 .clamp(1, 6);
329 (format!("<h{level}>"), format!("</h{level}>"))
330 }
331 "bullet_list" => ("<ul>".to_string(), "</ul>".to_string()),
332 "ordered_list" => ("<ol>".to_string(), "</ol>".to_string()),
333 "list_item" => ("<li>".to_string(), "</li>".to_string()),
334 "blockquote" => ("<blockquote>".to_string(), "</blockquote>".to_string()),
335 // Unknown: render children with no wrapper. See the
336 // module comment — silence here is data loss.
337 _ => (String::new(), String::new()),
338 };
339 out.push_str(&open);
340 for child in e.children(txn) {
341 node_html(txn, &child, out);
342 }
343 out.push_str(&close);
344 }
345 XmlOut::Fragment(f) => {
346 for child in f.children(txn) {
347 node_html(txn, &child, out);
348 }
349 }
350 }
351}
352
353/// The document rendered as HTML, escaped and ready to place inside the
354/// page container.
355pub fn fragment_html(doc: &Doc) -> String {
356 let f = root(doc);
357 let txn = doc.transact();
358 let mut out = String::new();
359 for child in f.children(&txn) {
360 node_html(&txn, &child, &mut out);
361 }
362 out
363}
364
365fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) {
366 match node {
367 XmlOut::Text(t) => cur.push_str(&t.get_string(txn)),
368 XmlOut::Element(e) => {
369 let name = e.tag().to_string();
370 let is_block = matches!(
371 name.as_str(),
372 "paragraph" | "heading" | "list_item" | "blockquote"
373 );
374 for child in e.children(txn) {
375 node_text(txn, &child, blocks, cur);
376 }
377 if is_block && !cur.is_empty() {
378 blocks.push(std::mem::take(cur));
379 }
380 }
381 XmlOut::Fragment(f) => {
382 for child in f.children(txn) {
383 node_text(txn, &child, blocks, cur);
384 }
385 }
386 }
387}
388
389/// The document as plain text, one blank line between blocks. Used by
390/// the list's preview and by anything that wants the words without the
391/// markup.
392pub fn fragment_text(doc: &Doc) -> String {
393 let f = root(doc);
394 let txn = doc.transact();
395 let mut blocks = Vec::new();
396 let mut cur = String::new();
397 for child in f.children(&txn) {
398 node_text(&txn, &child, &mut blocks, &mut cur);
399 }
400 if !cur.is_empty() {
401 blocks.push(cur);
402 }
403 blocks.join("\n\n")
404}
405```
406
407Add to `core/src/lib.rs`:
408
409```rust
410mod fragment;
411pub use fragment::{fragment_html, fragment_text, ROOT as FRAGMENT_ROOT};
412```
413
414Keep `text_content` exactly as it is. D5: the `Y.Text` fixtures are wire coverage and stay.
415
416- [ ] **Step 5: Run the tests**
417
418```bash
419cd core && cargo test --test fragment
420```
421
422Expected: all seven pass. If `yrs` 0.23.5's `diff`/`children` signatures differ from the above, adapt the call sites — do not change what the tests assert. The assertions are the contract.
423
424- [ ] **Step 6: Commit**
425
426```bash
427git add core/src/fragment.rs core/src/lib.rs core/tests/fragment.rs \
428 fixtures/generate-fragment.mjs fixtures/vectors/fragment
429git commit -m "core: render a ProseMirror XmlFragment to text and HTML"
430```
431
432---
433
434### Task 2: Expose HTML across the ABI
435
436The Go host can call `text_len`/`text_read` but has no way to ask for HTML. The read view needs it.
437
438**Files:**
439- Modify: `core/src/abi.rs`
440- Modify: `internal/core/core.go`
441- Test: `internal/core/core_test.go`
442
443**Interfaces:**
444- Consumes: `fragment_html` from Task 1.
445- Produces:
446 - `docs_core_html_len(handle) -> i32` (negative is an error, matching `docs_core_text_len`)
447 - `docs_core_html_read(handle, out_ptr, out_len) -> i32`
448 - `docs_core_abi_version() -> 2`
449 - Go: `func (h *Handle) HTML(ctx context.Context) (string, error)`
450
451- [ ] **Step 1: Write the failing Go test**
452
453In `internal/core/core_test.go`:
454
455```go
456func TestHandleHTMLRendersFragment(t *testing.T) {
457 ctx := context.Background()
458 rt, err := core.New(ctx)
459 if err != nil {
460 t.Fatalf("core.New: %v", err)
461 }
462 defer rt.Close(ctx)
463
464 h, err := rt.NewDoc(ctx, "html-test", 1)
465 if err != nil {
466 t.Fatalf("NewDoc: %v", err)
467 }
468 defer h.Close(ctx)
469
470 update, err := os.ReadFile("../../fixtures/vectors/fragment/simple.bin")
471 if err != nil {
472 t.Fatalf("read fixture: %v", err)
473 }
474 if err := h.Apply(ctx, update); err != nil {
475 t.Fatalf("Apply: %v", err)
476 }
477
478 got, err := h.HTML(ctx)
479 if err != nil {
480 t.Fatalf("HTML: %v", err)
481 }
482 want := "<p>First paragraph.</p><p>Second paragraph.</p>"
483 if got != want {
484 t.Errorf("HTML() = %q, want %q", got, want)
485 }
486}
487
488// The host pins the ABI it understands. A module built from a
489// different contract must be refused loudly, not read as garbage.
490func TestABIVersionIsPinnedAtTwo(t *testing.T) {
491 ctx := context.Background()
492 rt, err := core.New(ctx)
493 if err != nil {
494 t.Fatalf("core.New: %v", err)
495 }
496 defer rt.Close(ctx)
497 // core.New itself refuses a mismatch; reaching here means the
498 // module reported exactly what this host expects.
499}
500```
501
502- [ ] **Step 2: Run it and watch it fail**
503
504```bash
505go test ./internal/core/ -run TestHandleHTMLRendersFragment
506```
507
508Expected: compile failure — `h.HTML` undefined.
509
510- [ ] **Step 3: Add the ABI functions**
511
512In `core/src/abi.rs`, mirror `docs_core_text_len` and `docs_core_text_read` exactly — same error conventions, same bounds checks, same negative-is-error contract. Then bump:
513
514```rust
515#[no_mangle]
516pub extern "C" fn docs_core_abi_version() -> u32 {
517 2
518}
519```
520
521- [ ] **Step 4: Bump the host's pin and add `HTML`**
522
523In `internal/core/core.go`, change `const abiVersion = 1` to `2`, and add `HTML` mirroring `Text` — the two-call len-then-read dance, the same guest-call timeout, the same `RecoveredError` handling.
524
525- [ ] **Step 5: Rebuild the wasm and run the tests**
526
527```bash
528./scripts/build-wasm.sh && go test ./internal/core/ ./internal/docs/
529```
530
531Expected: pass. The embedded-wasm staleness check in the gate compares the committed artifact against a fresh build — if it complains, commit the rebuilt `.wasm`.
532
533- [ ] **Step 6: Commit**
534
535```bash
536git add core/src/abi.rs internal/core/core.go internal/core/core_test.go internal/docs/*.wasm
537git commit -m "core: ABI v2 — read a document as HTML"
538```
539
540---
541
542### Task 3: Seed documents become ProseMirror fragments, and the read view renders them
543
544The six seeded documents are `Y.Text` updates. After Task 1 they would read back empty through the fragment path. This task moves them and switches the read view to server-rendered HTML.
545
546**Files:**
547- Modify: `fixtures/generate-seed.mjs`
548- Modify: `internal/docs/seeddata/*.update.bin` (regenerated)
549- Modify: `internal/docs/store.go` (`Text` → also `HTML`)
550- Modify: `internal/docs/handlers.go` (show)
551- Modify: `internal/docs/views/show.html`
552- Test: `internal/docs/seed_test.go`, `internal/docs/render_test.go`
553
554**Interfaces:**
555- Consumes: `(*core.Handle).HTML` from Task 2.
556- Produces: `func (s *Store) HTML(ctx context.Context, doc *Document) (template.HTML, error)`
557
558- [ ] **Step 1: Update the seed generator to emit blocks**
559
560In `fixtures/generate-seed.mjs`, replace `doc.getText('content').insert(0, text)` with fragment construction, splitting the `.txt` source on blank lines into paragraphs. A line that is short, has no trailing full stop, and is followed by a blank line becomes a `heading` with `level="2"` — this is what gives "Early years" in the waterworks history its real shape, and demonstrates the schema doing work.
561
562```js
563function blocksFor(text) {
564 const paras = text.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean)
565 return paras.map((p, i) => {
566 const looksLikeHeading =
567 p.length < 60 && !p.includes('\n') && !/[.!?:]$/.test(p) && i !== 0
568 if (looksLikeHeading) {
569 const h = new Y.XmlElement('heading')
570 h.setAttribute('level', '2')
571 const t = new Y.XmlText(); t.insert(0, p); h.insert(0, [t])
572 return h
573 }
574 const el = new Y.XmlElement('paragraph')
575 const t = new Y.XmlText(); t.insert(0, p); el.insert(0, [t])
576 return el
577 })
578}
579```
580
581Keep `doc.clientID = 1`. Regenerate: `cd fixtures && node generate-seed.mjs`.
582
583- [ ] **Step 2: Write the failing test**
584
585`internal/docs/seed_test.go` already checks the `.update.bin` fixtures against their `.txt` sources. Extend it:
586
587```go
588// The seeded documents must survive the round trip through the CRDT as
589// HTML, not merely as bytes. A fixture that applies cleanly but renders
590// to nothing is the failure this test exists to catch.
591func TestSeededDocumentsRenderAsHTML(t *testing.T) {
592 app, err := NewForTest(t)
593 if err != nil {
594 t.Fatalf("NewForTest: %v", err)
595 }
596 ctx := context.Background()
597 owner := app.TestPerson(t, "render@example.com")
598 if err := app.Store().Seed(ctx, owner); err != nil {
599 t.Fatalf("Seed: %v", err)
600 }
601 docs, err := app.Store().List(ctx, owner)
602 if err != nil {
603 t.Fatalf("List: %v", err)
604 }
605 for _, d := range docs {
606 html, err := app.Store().HTML(ctx, &d)
607 if err != nil {
608 t.Fatalf("HTML(%q): %v", d.Title, err)
609 }
610 if !strings.Contains(string(html), "<p>") {
611 t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html)
612 }
613 }
614}
615
616func TestWaterworksHistoryHasHeadings(t *testing.T) {
617 app, err := NewForTest(t)
618 if err != nil {
619 t.Fatalf("NewForTest: %v", err)
620 }
621 ctx := context.Background()
622 owner := app.TestPerson(t, "headings@example.com")
623 if err := app.Store().Seed(ctx, owner); err != nil {
624 t.Fatalf("Seed: %v", err)
625 }
626 docs, _ := app.Store().List(ctx, owner)
627 for _, d := range docs {
628 if !strings.Contains(d.Title, "Millbrook") {
629 continue
630 }
631 html, err := app.Store().HTML(ctx, &d)
632 if err != nil {
633 t.Fatalf("HTML: %v", err)
634 }
635 if !strings.Contains(string(html), "<h2>") {
636 t.Error("the long history rendered no headings — the block " +
637 "classifier in generate-seed.mjs is not doing its job")
638 }
639 return
640 }
641 t.Fatal("the Millbrook waterworks document is missing from the seed set")
642}
643```
644
645- [ ] **Step 3: Run it and watch it fail**
646
647```bash
648go test ./internal/docs/ -run 'TestSeededDocumentsRenderAsHTML|TestWaterworksHistoryHasHeadings'
649```
650
651Expected: `Store.HTML` undefined.
652
653- [ ] **Step 4: Add `Store.HTML` and render it**
654
655In `store.go`, mirror `Text` exactly — same replay, same `defer h.Close(ctx)` — returning `template.HTML`. Returning the typed value rather than a string is deliberate: it puts the "this is trusted markup" decision in one place, next to the escaping that earns it, rather than at the template.
656
657In `handlers.go`'s show handler, call `HTML` instead of `Text`. In `show.html`, replace the `pre-wrap` text node with `{{.HTML}}` inside the existing `[docs-text]` container. Keep `[docs-canvas]`/`[docs-page]` exactly as they are (D3).
658
659Add CSS for `h2`, `p`, `ul`, `ol`, `li`, `blockquote`, `strong`, `em` within `[docs-text]`, keeping the canvas's `color-scheme: light` and literal colours — the page is paper and must not follow the reader's dark mode.
660
661- [ ] **Step 5: Run the full suite**
662
663```bash
664go test ./... && .amadan/ci.d/10-gate
665```
666
667Expected: both green. The gate will flag the changed `seeddata/*.update.bin` only if the generator is nondeterministic — if it does, the client id is not pinned.
668
669- [ ] **Step 6: Look at it**
670
671```bash
672./scripts/run-dev.sh
673```
674
675Sign in, open the waterworks history, confirm headings and paragraphs render. Paste the real HTML into your report.
676
677- [ ] **Step 7: Commit**
678
679```bash
680git add fixtures/generate-seed.mjs internal/docs/seeddata internal/docs/store.go \
681 internal/docs/handlers.go internal/docs/views/show.html internal/docs/seed_test.go
682git commit -m "app: seed documents as ProseMirror fragments, render the read view server-side"
683```
684
685---
686
687### Task 4: Vendor the editor bundle
688
689§6.5 keeps ProseMirror partly because there is no build step. That means a *committed prebuilt bundle*, not a build the app performs. There is no `static/` directory yet.
690
691**Files:**
692- Create: `fixtures/build-editor.mjs`, `fixtures/editor-entry.js`
693- Create: `static/editor.js` (built artifact, committed)
694- Create: `internal/docs/assets.go`
695- Test: `internal/docs/assets_test.go`
696
697**Interfaces:**
698- Produces: `GET /static/editor.<hash>.js`, and `func editorScriptPath() string` for the template.
699
700- [ ] **Step 1: Add the dependencies and the entry point**
701
702In `fixtures/package.json`, add `prosemirror-state`, `prosemirror-view`, `prosemirror-model`, `prosemirror-schema-basic`, `prosemirror-schema-list`, `prosemirror-keymap`, `prosemirror-commands`, `prosemirror-history`, `y-prosemirror`, and `esbuild`, all pinned to exact versions. `yjs` stays at `13.6.27` — the same version the wire fixtures are generated with, which is what makes the fixtures meaningful.
703
704`fixtures/editor-entry.js` re-exports what `static/editor.js` needs, so the bundle's surface is explicit rather than incidental.
705
706- [ ] **Step 2: Write the build script**
707
708```js
709// fixtures/build-editor.mjs — run by a person, output committed.
710// The app performs no build step (spec §6.5); this produces the
711// artifact that makes that true.
712import { build } from 'esbuild'
713import { createHash } from 'node:crypto'
714import { readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
715
716await build({
717 entryPoints: ['editor-entry.js'],
718 bundle: true, format: 'esm', minify: true, target: 'es2020',
719 outfile: '../static/editor.js',
720})
721
722// Content hash in the filename so the asset is immutable-cacheable and
723// a stale browser cache cannot serve an editor that disagrees with the
724// server's ABI.
725const bytes = readFileSync('../static/editor.js')
726const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
727for (const f of readdirSync('../static')) {
728 if (/^editor\.[0-9a-f]{16}\.js$/.test(f)) rmSync(`../static/${f}`)
729}
730writeFileSync(`../static/editor.${hash}.js`, bytes)
731rmSync('../static/editor.js')
732console.log(`wrote static/editor.${hash}.js (${bytes.length} bytes)`)
733```
734
735- [ ] **Step 3: Write the failing test**
736
737```go
738// The bundle is committed, not built at boot. This asserts exactly one
739// is present and that the path the template emits resolves to it — a
740// mismatch would serve a 404 and leave the editor silently absent,
741// which looks identical to "editing is not implemented yet".
742func TestExactlyOneEditorBundleIsEmbedded(t *testing.T) {
743 entries, err := fs.ReadDir(staticFS, "static")
744 if err != nil {
745 t.Fatalf("read static: %v", err)
746 }
747 var found []string
748 for _, e := range entries {
749 if strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") {
750 found = append(found, e.Name())
751 }
752 }
753 if len(found) != 1 {
754 t.Fatalf("want exactly one editor bundle, got %v", found)
755 }
756 if got := editorScriptPath(); got != "/static/"+found[0] {
757 t.Errorf("editorScriptPath() = %q, want %q", got, "/static/"+found[0])
758 }
759}
760
761func TestEditorBundleIsServed(t *testing.T) {
762 app, err := NewForTest(t)
763 if err != nil {
764 t.Fatalf("NewForTest: %v", err)
765 }
766 rec := app.TestRequest(t, "", http.MethodGet, editorScriptPath(), nil)
767 if rec.Code != http.StatusOK {
768 t.Fatalf("GET %s = %d, want 200", editorScriptPath(), rec.Code)
769 }
770 if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
771 t.Errorf("Content-Type = %q, want javascript", ct)
772 }
773}
774```
775
776Note the empty address in `TestRequest`: the bundle must be served **without** a session, or a signed-out reader gets a broken page. Static assets are not behind `RequireSession`.
777
778- [ ] **Step 4: Run it, watch it fail, then implement**
779
780`internal/docs/assets.go`: `//go:embed static` plus a handler mounted at `/static/`, `Cache-Control: public, max-age=31536000, immutable` (safe because the name carries the content hash), and `editorScriptPath()` resolving the single bundle at init. If there is not exactly one, panic at init — a wrong number of bundles is a build mistake that must never reach a running instance.
781
782- [ ] **Step 5: Commit**
783
784```bash
785git add fixtures/package.json fixtures/package-lock.json fixtures/editor-entry.js \
786 fixtures/build-editor.mjs static internal/docs/assets.go internal/docs/assets_test.go
787git commit -m "app: vendor the prebuilt editor bundle"
788```
789
790---
791
792### Task 5: The update log endpoints
793
794The browser needs the document's updates to build its Yjs doc, and a way to send new ones back.
795
796**Files:**
797- Modify: `internal/docs/handlers.go`, `internal/docs/app.go` (routes)
798- Test: `internal/docs/updates_test.go`
799
800**Interfaces:**
801- Produces:
802 - `GET /d/{sqid}/updates` → `application/octet-stream`, the concatenated update log, owner-scoped.
803 - `POST /d/{sqid}/updates` → applies one update via `Store.ApplyUpdate`, 204 on success.
804
805- [ ] **Step 1: Write the failing tests**
806
807```go
808// Both endpoints are owner-scoped. A stranger must get 404, never 403:
809// 403 confirms the document exists, which is the enumeration leak the
810// read side already avoids.
811func TestUpdatesEndpointsAreOwnerScoped(t *testing.T) {
812 app, err := NewForTest(t)
813 if err != nil {
814 t.Fatalf("NewForTest: %v", err)
815 }
816 ctx := context.Background()
817 owner := app.TestPerson(t, "owner@example.com")
818 app.TestPerson(t, "stranger@example.com")
819 doc, err := app.Store().Create(ctx, owner, "Private")
820 if err != nil {
821 t.Fatalf("Create: %v", err)
822 }
823
824 get := app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
825 if get.Code != http.StatusNotFound {
826 t.Errorf("stranger GET = %d, want 404", get.Code)
827 }
828 post := app.TestRequest(t, "stranger@example.com", http.MethodPost,
829 "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t)))
830 if post.Code != http.StatusNotFound {
831 t.Errorf("stranger POST = %d, want 404", post.Code)
832 }
833}
834
835// The round trip is the whole feature: what the browser posts must come
836// back out, through a real CRDT replay.
837func TestPostedUpdateSurvivesRoundTrip(t *testing.T) {
838 app, err := NewForTest(t)
839 if err != nil {
840 t.Fatalf("NewForTest: %v", err)
841 }
842 ctx := context.Background()
843 owner := app.TestPerson(t, "owner@example.com")
844 doc, _ := app.Store().Create(ctx, owner, "Round trip")
845
846 update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
847 rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
848 "/d/"+doc.Sqid+"/updates", bytes.NewReader(update))
849 if rec.Code != http.StatusNoContent {
850 t.Fatalf("POST = %d, want 204: %s", rec.Code, rec.Body)
851 }
852
853 html, err := app.Store().HTML(ctx, doc)
854 if err != nil {
855 t.Fatalf("HTML: %v", err)
856 }
857 if !strings.Contains(string(html), "First paragraph.") {
858 t.Errorf("posted update did not reach the document: %q", html)
859 }
860
861 got := app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
862 if got.Code != http.StatusOK {
863 t.Fatalf("GET = %d, want 200", got.Code)
864 }
865 if got.Body.Len() == 0 {
866 t.Error("GET returned an empty log after a successful POST")
867 }
868}
869
870// Garbage must be refused as a client error, not a 500, and must not be
871// persisted. yrs calls from_utf8_unchecked on wire data (spec §5.2), so
872// this path is exactly why the wasm sandbox is load-bearing.
873func TestGarbageUpdateIsRejectedAndNotPersisted(t *testing.T) {
874 app, err := NewForTest(t)
875 if err != nil {
876 t.Fatalf("NewForTest: %v", err)
877 }
878 ctx := context.Background()
879 owner := app.TestPerson(t, "owner@example.com")
880 doc, _ := app.Store().Create(ctx, owner, "Garbage")
881
882 rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
883 "/d/"+doc.Sqid+"/updates", bytes.NewReader([]byte{0xff, 0x00, 0x13, 0x37}))
884 if rec.Code != http.StatusBadRequest {
885 t.Errorf("garbage POST = %d, want 400", rec.Code)
886 }
887 got := app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
888 if got.Body.Len() != 0 {
889 t.Error("a rejected update was persisted anyway")
890 }
891}
892
893// An oversized body must be refused before it is read into memory.
894func TestOversizedUpdateIsRefused(t *testing.T) {
895 app, err := NewForTest(t)
896 if err != nil {
897 t.Fatalf("NewForTest: %v", err)
898 }
899 ctx := context.Background()
900 owner := app.TestPerson(t, "owner@example.com")
901 doc, _ := app.Store().Create(ctx, owner, "Huge")
902
903 rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
904 "/d/"+doc.Sqid+"/updates", bytes.NewReader(make([]byte, maxUpdateBytes+1)))
905 if rec.Code != http.StatusRequestEntityTooLarge {
906 t.Errorf("oversized POST = %d, want 413", rec.Code)
907 }
908}
909```
910
911- [ ] **Step 2: Run them and watch them fail**
912
913```bash
914go test ./internal/docs/ -run 'Updates|RoundTrip|Garbage|Oversized'
915```
916
917Expected: 404 for unrouted paths, which is a *false pass* on the owner-scoping test. Note that in your report and confirm the tests fail for the right reason after the routes exist.
918
919- [ ] **Step 3: Implement**
920
921Add `const maxUpdateBytes = 1 << 20` with a comment on why a megabyte: a single keystroke's update is tens of bytes, a whole seeded document is a few kilobytes, so a megabyte is far above legitimate use and far below anything that threatens the instance.
922
923Wrap the body in `http.MaxBytesReader`. Load the document with `Store.Get(ctx, owner, sqid)` — already owner-scoped, already 404-not-403 — then `Store.ApplyUpdate`, which re-derives ownership itself. A rejection from the core is a 400, not a 500: it is a client sending bad bytes.
924
925For `GET`, stream the rows in `seq` order. Cap the response at `maxLogBytes = 8 << 20` and, on exceeding it, fail with 500 and a log line naming the document — per D2 this is the bound that a later snapshot plan removes, and it must be loud rather than silently truncating a document into corruption.
926
927CSRF: rastrillo's protection is origin-checking, so the POST needs a correct `Origin`. `TestRequest` must send one. Do not exempt the route.
928
929- [ ] **Step 4: Run the tests, then the gate**
930
931```bash
932go test ./internal/docs/ && .amadan/ci.d/10-gate
933```
934
935- [ ] **Step 5: Commit**
936
937```bash
938git add internal/docs/handlers.go internal/docs/app.go internal/docs/updates_test.go
939git commit -m "app: update-log endpoints, owner-scoped and size-capped"
940```
941
942---
943
944### Task 6: The editor
945
946Bind ProseMirror to the Yjs fragment, load the log, and post updates as they happen.
947
948**Files:**
949- Create: `fixtures/editor-entry.js` (filled in properly here)
950- Modify: `internal/docs/views/show.html`
951- Modify: `internal/docs/render.go` (catalog keys)
952- Test: `internal/docs/editor_view_test.go`
953
954- [ ] **Step 1: Write the editor**
955
956```js
957import * as Y from 'yjs'
958import { EditorState } from 'prosemirror-state'
959import { EditorView } from 'prosemirror-view'
960import { Schema } from 'prosemirror-model'
961import { schema as basic } from 'prosemirror-schema-basic'
962import { addListNodes } from 'prosemirror-schema-list'
963import { keymap } from 'prosemirror-keymap'
964import { baseKeymap, toggleMark } from 'prosemirror-commands'
965import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror'
966
967const schema = new Schema({
968 nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'),
969 marks: basic.spec.marks,
970})
971
972export async function mountEditor(el) {
973 const sqid = el.getAttribute('data-sqid')
974 const base = `/d/${sqid}/updates`
975 const ydoc = new Y.Doc()
976
977 // Load: replay the server's log. Applying inside one transaction
978 // keeps the editor from rendering half a document.
979 const res = await fetch(base, { credentials: 'same-origin' })
980 if (!res.ok) throw new Error(`load ${base}: ${res.status}`)
981 const log = new Uint8Array(await res.arrayBuffer())
982 if (log.length) Y.applyUpdate(ydoc, log, 'server')
983
984 const fragment = ydoc.getXmlFragment('prosemirror')
985
986 // Send only what originated here. The 'server' origin above marks
987 // replayed bytes, and echoing those back would grow the log without
988 // adding information.
989 let queue = []
990 let timer = null
991 ydoc.on('update', (update, origin) => {
992 if (origin === 'server') return
993 queue.push(update)
994 if (timer) return
995 // Coalesce a burst of keystrokes into one request. 400ms is below
996 // the threshold where a person notices a save is pending and well
997 // above per-character chatter.
998 timer = setTimeout(flush, 400)
999 })
1000
1001 async function flush() {
1002 timer = null
1003 if (!queue.length) return
1004 const merged = Y.mergeUpdates(queue)
1005 queue = []
1006 try {
1007 const r = await fetch(base, {
1008 method: 'POST',
1009 credentials: 'same-origin',
1010 headers: { 'Content-Type': 'application/octet-stream' },
1011 body: merged,
1012 })
1013 if (!r.ok) throw new Error(`save: ${r.status}`)
1014 el.removeAttribute('data-save-failed')
1015 } catch (err) {
1016 // Put the bytes back so the next flush retries them. Dropping
1017 // them would lose the edit silently, which is the one outcome a
1018 // text editor may never produce.
1019 queue.unshift(merged)
1020 el.setAttribute('data-save-failed', '1')
1021 console.error(err)
1022 }
1023 }
1024
1025 // A close or reload with a pending timer must not lose the edit.
1026 addEventListener('beforeunload', () => {
1027 if (!queue.length) return
1028 navigator.sendBeacon(base, new Blob([Y.mergeUpdates(queue)],
1029 { type: 'application/octet-stream' }))
1030 })
1031
1032 const view = new EditorView(el, {
1033 state: EditorState.create({
1034 schema,
1035 plugins: [
1036 ySyncPlugin(fragment),
1037 yUndoPlugin(),
1038 keymap({
1039 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo,
1040 'Mod-b': toggleMark(schema.marks.strong),
1041 'Mod-i': toggleMark(schema.marks.em),
1042 }),
1043 keymap(baseKeymap),
1044 ],
1045 }),
1046 })
1047 return view
1048}
1049
1050const mount = document.querySelector('[docs-editor]')
1051if (mount) mountEditor(mount).catch(err => {
1052 // Leave the server-rendered read view in place rather than replacing
1053 // it with a blank editor: unreadable is worse than uneditable.
1054 mount.setAttribute('data-editor-failed', '1')
1055 console.error(err)
1056})
1057```
1058
1059- [ ] **Step 2: Rebuild the bundle**
1060
1061```bash
1062cd fixtures && npm ci && node build-editor.mjs
1063```
1064
1065Commit the new hashed artifact and delete the old one — Task 4's test asserts exactly one exists.
1066
1067- [ ] **Step 3: Write the failing view test**
1068
1069```go
1070// The read view must render its content server-side whether or not the
1071// editor loads. Spec §6.2: reading works with no script. A page that
1072// renders only after the bundle executes is a regression against that,
1073// and it is invisible in a browser that runs the script.
1074func TestShowRendersContentWithoutScript(t *testing.T) {
1075 app, err := NewForTest(t)
1076 if err != nil {
1077 t.Fatalf("NewForTest: %v", err)
1078 }
1079 ctx := context.Background()
1080 owner := app.TestPerson(t, "reader@example.com")
1081 doc, _ := app.Store().Create(ctx, owner, "Readable")
1082 update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
1083 if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
1084 t.Fatalf("ApplyUpdate: %v", err)
1085 }
1086
1087 rec := app.TestRequest(t, "reader@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
1088 body := rec.Body.String()
1089 if !strings.Contains(body, "First paragraph.") {
1090 t.Error("the document's text is not in the server-rendered HTML")
1091 }
1092 if !strings.Contains(body, "docs-editor") {
1093 t.Error("no editor mount point")
1094 }
1095 if !strings.Contains(body, editorScriptPath()) {
1096 t.Error("the editor bundle is not referenced")
1097 }
1098}
1099```
1100
1101- [ ] **Step 4: Add the mount point**
1102
1103In `show.html`, give the existing `[docs-text]` container `docs-editor` and `data-sqid="{{.Sqid}}"`, and load the bundle with `<script type="module" defer src="{{.EditorScript}}"></script>`. ProseMirror replaces the container's contents on mount, so the server-rendered HTML is what a scriptless reader sees and what a scripted reader sees until the editor takes over.
1104
1105- [ ] **Step 5: Run everything**
1106
1107```bash
1108go test ./... && .amadan/ci.d/10-gate
1109```
1110
1111- [ ] **Step 6: Actually edit a document**
1112
1113```bash
1114./scripts/run-dev.sh
1115```
1116
1117Sign in, open a seeded document, type a sentence, wait a second, **reload**, and confirm it is still there. Then check the read view still renders with JavaScript disabled. Paste both results into your report. This step is the plan's whole purpose — a green suite that has not been exercised this way proves nothing about whether a person can edit a document.
1118
1119- [ ] **Step 7: Commit**
1120
1121```bash
1122git add fixtures/editor-entry.js static internal/docs/views/show.html \
1123 internal/docs/render.go internal/docs/editor_view_test.go
1124git commit -m "app: edit a document — ProseMirror bound to the shared fragment"
1125```
1126
1127---
1128
1129## Self-review
1130
1131**Spec coverage.** §6.1 pagination — deliberately deferred (D3), and `show.html`'s existing comment already sequences it. §6.2 scriptless reading — Task 3 renders server-side, Task 6 pins it with a test. §6.3 toolbar/inspector — deferred (D4). §6.4 list — already shipped. §6.5 ProseMirror — Tasks 4 and 6. §7.1 WebSocket transport — deferred (D1); the update-log format this plan establishes is what a socket will stream. §7.2 offline/IndexedDB — not in this plan; it needs the socket. §7.3 presence and hibernation — not in this plan; no socket is opened, so no instance is pinned awake, which is why D1 is also the conservative choice.
1132
1133**Gaps consciously left open.** The update log grows without bound until §4.1's snapshot plan lands; Task 5 caps it loudly rather than silently. Two browser tabs on one document will diverge until the collaboration plan adds a socket — single-user editing is what this plan claims. There is no conflict UI because there is no live merge yet.
1134
1135**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.
1136
1137**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:
1138
1139- `Doc::get_or_insert_xml_fragment(name) -> XmlFragmentRef` — exists, `doc.rs:253`.
1140- The child enum is **`XmlOut`**, not `XmlNode` — `types/xml.rs:89`, variants `Element(XmlElementRef)`, `Fragment(XmlFragmentRef)`, `Text(XmlTextRef)`. The code above uses `XmlOut`.
1141- `children(txn)` yields `XmlOut` — `types/xml.rs:1223`.
1142- `XmlElementRef::tag() -> &Arc<str>` — `types/xml.rs:280`.
1143- `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()`.
1144
1145**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.
1146
1147The assertions in Step 2 are the contract; adapt call sites, never the tests.
1148