fix: href round-trips through y-prosemirror; marks named after Y attributes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
core/src/schema.rs+121 −8docs/superpowers/specs/2026-09-07-slides-design.md+26 −5fixtures/schema.mjs+24 −14fixtures/schema.test.mjs+105 −0fixtures/vectors/every-block.yjsbinaryfixtures/vectors/title-only.yjsbinaryinternal/slides/core/testdata/slides_core.wasmbinary
Intent
<task-notification> <task-id>af3a227c5db60371e</task-id> <tool-use-id>toolu_013Sy2u46jf5GcfH2pR6YL9s</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/af3a227c5db60371e.output</output-file> <status>completed</status> <summary>Agent "Slice 2 Task 2: JS ops and contract" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>The other agent committed its Task 1 work before mine (`055f75e`); my commit contains only the 31 files under `fixtures/` and `internal/slides/core/`, and the tree is clean. **Status:** DONE_WITH_CONCERNS **Commit:** `88215d8` — `feat: fixtures toolchain, JS ops, Rust contract vectors` (31 files: `fixtures/**` incl. lockfile, 5 data files, 10 vectors; `internal/slides/core/vectors_test.go`) **Tests:** `node --test fixtures/` 15/15 green; `go test ./internal/slides/core/` green — `TestVectorsViewAsTheJSSideViewsThem` (JS-built `.yjs` → core `Apply` → `JSON` equals `.view.json`) and `TestVectorsOpsAgreeWithTheJSOps` (same ops through the core's `Ops` equals the JS view), 5 vectors each; `make ci` green. RED evidence: module-not-found, then a deliberately corrupted vector failed both Go tests with a readable line diff before regeneration. **Concerns:** 1. **`href` does not round-trip through y-prosemirror 1.3.7** (verified empirically both ways). It hard-codes `attrs[mark.type.name] = mark.attrs`: `b/i/c/h` bind fine (marks are named after the attributes; `{}` reads as truthy in the core), but the core's plain-string `href` reads back as `href: null` and PM writes `{href: {href: url}}` which the core drops. Recommend a small core change: write `href` as `{href: url}` and read either shape, regenerate vectors in the same commit; JS side is two lines. Without it, Task 4's stage binding loses links on first edit. 2. Marks are named `b, i, c, h, href`, not the spec's `bold, italic, code, highlight, link` — spec §2.6 should be amended. 3. Split by pass (`ops-parse` / `ops-model` / `ops-apply`) rather than by op family to stay under 300 lines; `ops.test.mjs` is 319 (tests, not a module). `npm install` needed `--cache $TMPDIR/npm-cache` (sandbox denies `~/.npm/_cacache`); `npm ci` from the lockfile verified. **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-2-editor/task-2-report.md`</result> <usage><subagent_tokens>213776</subagent_tokens><tool_uses>70</tool_uses><duration_ms>1077081</duration_ms></usage> </task-notification>
<task-notification> <task-id>aae8c4477b363f8e8</task-id> <tool-use-id>toolu_017ZDFD7XC13QL7tzkVNZjof</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aae8c4477b363f8e8.output</output-file> <status>completed</status> <summary>Agent "Review slice 2 Task 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>## Output ### Spec Compliance All controller decisions are met, verified against the diff and read against the plan's Global Constraints and spec §3.3: - `live.Hub` in `internal/slides/live/hub.go`: `New`, `Subscribe`, `PublishSeq`, `PublishPresence`, `Snapshot`, `Presence`, `Event`, TTL=45s, sweeper every 15s (a third of TTL) — matches. - `appendUpdate` (`internal/slides/live.go`) opens a fresh handle, runs `h.Apply` inside the same transaction shape as `applyOps`/`commitUpdate`, same optimistic lock (`WHERE id = ? AND seq = ?`) and the same `errDeckBusy` re-read-and-retry loop (once) as `editDeck`. A refusal (`core.Rejected`/`core.Recovered`) is 422; anything else is 500. - `PublishSeq` after commit, then best-effort `maybeSnapshot`, in that order, from the same handle — both in `appendUpdate` and in `applyOps` (`internal/slides/decks.go:330-339`). - `openDeck` (`internal/slides/decks.go`) now goes through `replayPlan`: newest snapshot + `seq > snapshot.Seq`, matches. - `GET updates?since=0` answers the snapshot as the first frame with `Slides-Snapshot`, and the tail query is `seq > snap.Seq` — never re-serves rows the snapshot covers. - Frames are 4-byte BE length + bytes, `Slides-Seq` = highest seq in the body or `since` when empty. - Both `updates` and `stream` are in the signed-in group, behind `deckFor`/`visibleDecks` (404 not 403), confirmed by `TestUpdatesRequireMembershipAndRecordActor`. - Presence POST `{client, slide, block}`, name/colour stamped server-side from the users row, never from the body — explicitly tested in `TestPresencePostReachesTheStream` (posts a spoofed name/colour, asserts they never appear). - SSE: `event: update`/`event: presence`, `: ping` every 15s, 30s write deadline, 5min TTL, connect emits update then presence, honours `r.Context().Done()` — matches `presentation.go`'s constants, reused rather than restated. - Every write records `actorFor(u)` — asserted for both owner and team member in `TestUpdatesRequireMembershipAndRecordActor`. - `GET /d/{sqid}/slides/{id}.html?vp=` twin exists in `editor.go`, signed-in group, same `render.Slide` call as the API's twin. - Migration `0006_create_deck_snapshots.sql` present and `schema.sql` regenerated; `make ci`'s migration check passed per the report. ### Strengths - The named risks were deliberately engineered around, not accidentally satisfied: `readUpdates`'s tail starts at `snap.Seq` (never re-sends log rows the snapshot covers); `Slides-Seq` is derived from bytes actually written to the response rather than a second `MAX(seq)` query, closing a real staleness window; presence field lengths and body size are capped. - `TestSnapshotWrittenAtBoundaryAndUsedOnOpen` is a real equivalence test, not a tautology: it compares the JSON view from a long-lived incrementally-applied handle against the view produced by `openDeck`'s snapshot+tail replay path, and again after deleting the snapshot — genuinely proving (b). - No `a.db.G` calls inside any `Transaction` callback anywhere in the diff (checked `live.go`, `decks.go`, `snapshot.go` mechanically) — rastrillo §3 held. - Verified independently: ran `TestSnapshotWrittenAtBoundaryAndUsedOnOpen`, `TestPresenceIsAnnouncedAndExpires`, and the full `slidestest` live suite (`TestUpdatesRoundTripThroughTheWire`, `TestUpdatesSinceCursorIsExclusive`, `TestStreamAnnouncesSeqOnConnectAndAfterPost`, `TestPresencePostReachesTheStream`, `TestUpdatesRequireMembershipAndRecordActor`, `TestRefusedUpdateIs422`, `TestSlideHTMLTwinRendersOneSection`) with `-race`; all pass. - The `hub.go` design docs (advisory seq vs. whole-state presence, why drop-on-overflow rather than skip, why `Event.Presence` is a pointer) are unusually precise and match the implementation exactly, not just narrate it. - The AGENTS.md edit corrects three paragraphs that were now factually wrong (openDeck's "there are no snapshots yet", the single-write-path claim, the missing `live.Hub` seam) — outside the brief's file list but a real, in-scope fix rather than scope creep. ### Issues #### Critical None. #### Important None. #### Minor - `errDeckBusy` → 409 on `appendUpdate`'s own path is untested (report's own concern #4). `decks_test.go`'s `TestConcurrentWritersConflictThenRetry` exercises the identical code shape via `applyOps`, and `commitUpdate`'s busy-detection is textually parallel, but there is no test that provokes two racing `POST /updates` calls on one deck. - The stream's 15s ping and 5-minute TTL are unasserted by any test (report's concern #5); a 10s-bounded test can't reach either without an injectable clock, which nothing here punches a seam for. Real but low-risk, since the constants are shared with `presentation.go`'s already-shipped stream. - No ABI-version column on `DeckSnapshot` (report's concern #2) — matches the brief's model exactly as specified, so this is a spec gap rather than an implementation gap; correctly flagged rather than silently worked around. - `readUpdates` buffers the whole answer in memory (report's concern #1) — acceptable given snapshot cadence bounds the tail to <200 rows; correctly identified as the line to revisit if that invariant ever breaks. ### Assessment **Task quality:** Approved **Reasoning:** Every controller decision in the brief is implemented as specified, and the eight named risk points hold up under direct code reading, not just narrative claims — including the two most failure-prone ones ((a) snapshot-from-committed-handle ordering, and (c) since=0 tail never overlapping the snapshot). The one test genuinely proving snapshot/log equivalence does real comparative work rather than asserting a tautology. `-race` passes on the full live-sync suite, and a mechanical check confirms no `a.db.G` call inside a `Transaction` callback. The report's self-flagged gaps (untested 409 on `appendUpdate`, untested ping/TTL, no ABI column) are honest, low-severity, and don't undermine the delivered behavior — they're candidates for a follow-up task, not reasons to send this back.</result> <usage><subagent_tokens>110039</subagent_tokens><tool_uses>27</tool_uses><duration_ms>164186</duration_ms></usage> </task-notification>
Attributed to this commit: 14.5 g CO₂e (1616375 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-2-editor, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/core/src/schema.rs b/core/src/schema.rs| index ddc7bdb..5ddabc6 100644 |
| --- a/core/src/schema.rs |
| +++ b/core/src/schema.rs |
| @@ -25,10 +25,18 @@ |
| //! |
| //! The text schema (§2.6): an XmlFragment of `<paragraph>` elements, each |
| //! holding one XmlText. Marks are formatting attributes on the text's |
| -//! runs: `b`, `i`, `c`, `h` (value `true`) and `href` (a string). The |
| -//! reader is more tolerant than the writer — several texts in one |
| -//! paragraph concatenate, a bare text at fragment level is its own |
| -//! paragraph — because updates reach the server from browsers as well. |
| +//! runs: `b`, `i`, `c`, `h` (value `true`) and `href` (the one-key map |
| +//! `{"href": url}`). The map is y-prosemirror's shape: it maps a mark to |
| +//! the attribute `<mark name> = <mark attrs object>`, so the mark `href` |
| +//! with attrs `{href}` is the attribute `href` with that object as its |
| +//! value, and a plain string there would throw in `schema.mark` (which |
| +//! makes y-prosemirror delete the text). The reader is more tolerant |
| +//! than the writer — `href` reads as either the map or a plain string |
| +//! (documents written before this shape), any truthy value counts as a |
| +//! set mark (y-prosemirror writes `{}` for the attr-less marks), |
| +//! several texts in one paragraph concatenate, and a bare text at |
| +//! fragment level is its own paragraph — because updates reach the |
| +//! server from browsers as well. |
| use serde::Serialize; |
| use std::collections::HashMap; |
| @@ -124,11 +132,33 @@ fn run_attrs(run: &Run) -> Attrs { |
| } |
| } |
| if let Some(href) = &run.href { |
| - attrs.insert(Arc::from("href"), Any::from(href.as_str())); |
| + attrs.insert(Arc::from("href"), href_any(href)); |
| } |
| attrs |
| } |
| +/// The `href` attribute's value: y-prosemirror's one-key map for the mark |
| +/// `href` with attrs `{href}`. |
| +fn href_any(href: &str) -> Any { |
| + Any::Map(Arc::new(HashMap::from([( |
| + "href".to_string(), |
| + Any::from(href), |
| + )]))) |
| +} |
| + |
| +/// The URL an `href` attribute carries: the `{"href": url}` map this |
| +/// module writes, or the plain string documents written before it carry. |
| +fn href_of(v: &Any) -> Option<String> { |
| + match v { |
| + Any::String(s) => Some(s.to_string()), |
| + Any::Map(m) => match m.get("href") { |
| + Some(Any::String(s)) => Some(s.to_string()), |
| + _ => None, |
| + }, |
| + _ => None, |
| + } |
| +} |
| + |
| fn paragraph_prelim(paragraph: &[Run]) -> XmlElementPrelim { |
| let delta: Vec<Delta<In>> = paragraph |
| .iter() |
| @@ -187,9 +217,7 @@ fn push_runs<T: ReadTxn>(txn: &T, text: &yrs::XmlTextRef, out: &mut Paragraph) { |
| run.i = attrs.get("i").is_some_and(truthy); |
| run.c = attrs.get("c").is_some_and(truthy); |
| run.h = attrs.get("h").is_some_and(truthy); |
| - if let Some(Any::String(href)) = attrs.get("href") { |
| - run.href = Some(href.to_string()); |
| - } |
| + run.href = attrs.get("href").and_then(href_of); |
| } |
| match out.last_mut() { |
| Some(last) if last.same_marks(&run) => last.t.push_str(&run.t), |
| @@ -521,3 +549,88 @@ pub fn box_json(x: f64, y: f64, w: f64, h: f64) -> serde_json::Value { |
| "h": round4(h), |
| }) |
| } |
| + |
| +#[cfg(test)] |
| +mod tests { |
| + use super::*; |
| + use crate::new_doc; |
| + use yrs::Transact; |
| + |
| + fn linked(t: &str, href: &str) -> Run { |
| + Run { |
| + t: t.to_string(), |
| + href: Some(href.to_string()), |
| + ..Run::default() |
| + } |
| + } |
| + |
| + /// Writes one paragraph holding one XmlText formatted with `attrs`, |
| + /// then reads the fragment back the way the view does. |
| + fn read_back_written_attrs(attrs: Attrs) -> Vec<Paragraph> { |
| + let doc = new_doc("test-href", 1); |
| + let frag = doc.get_or_insert_xml_fragment("t"); |
| + let mut txn = doc.transact_mut(); |
| + let el = frag.push_back(&mut txn, XmlElementPrelim::empty("paragraph")); |
| + let text = el.push_back(&mut txn, yrs::XmlTextPrelim::new("")); |
| + text.insert_with_attributes(&mut txn, 0, "x", attrs); |
| + read_text(&txn, &frag) |
| + } |
| + |
| + #[test] |
| + fn href_is_written_as_a_one_key_map_and_reads_back() { |
| + let doc = new_doc("test-href", 1); |
| + let frag = doc.get_or_insert_xml_fragment("t"); |
| + let mut txn = doc.transact_mut(); |
| + replace_text(&mut txn, &frag, &[vec![linked("t", "https://x")]]); |
| + |
| + // The stored attribute is y-prosemirror's shape, not a bare string. |
| + let Some(XmlOut::Element(el)) = frag.get(&txn, 0) else { |
| + panic!("the paragraph is an element"); |
| + }; |
| + let Some(XmlOut::Text(text)) = el.get(&txn, 0) else { |
| + panic!("the paragraph holds a text"); |
| + }; |
| + let chunks = text.diff(&txn, YChange::identity); |
| + let attrs = chunks[0].attributes.as_ref().expect("attributes"); |
| + assert_eq!(attrs.get("href"), Some(&href_any("https://x"))); |
| + |
| + assert_eq!(read_text(&txn, &frag), vec![vec![linked("t", "https://x")]]); |
| + } |
| + |
| + #[test] |
| + fn href_written_as_a_plain_string_still_reads_back() { |
| + // What documents written before the map shape carry. |
| + let attrs = Attrs::from([(Arc::from("href"), Any::from("https://old"))]); |
| + assert_eq!( |
| + read_back_written_attrs(attrs), |
| + vec![vec![linked("x", "https://old")]] |
| + ); |
| + } |
| + |
| + #[test] |
| + fn an_empty_map_reads_as_a_set_mark() { |
| + // y-prosemirror writes `{}` for a mark with no attributes. |
| + let attrs = Attrs::from([(Arc::from("b"), Any::Map(Arc::new(HashMap::new())))]); |
| + assert_eq!( |
| + read_back_written_attrs(attrs), |
| + vec![vec![Run { |
| + t: "x".to_string(), |
| + b: true, |
| + ..Run::default() |
| + }]] |
| + ); |
| + } |
| + |
| + #[test] |
| + fn an_href_map_without_a_string_url_reads_as_no_link() { |
| + let inner = HashMap::from([("href".to_string(), Any::Null)]); |
| + let attrs = Attrs::from([(Arc::from("href"), Any::Map(Arc::new(inner)))]); |
| + assert_eq!( |
| + read_back_written_attrs(attrs), |
| + vec![vec![Run { |
| + t: "x".to_string(), |
| + ..Run::default() |
| + }]] |
| + ); |
| + } |
| +} |
diff --git a/docs/superpowers/specs/2026-09-07-slides-design.md b/docs/superpowers/specs/2026-09-07-slides-design.md| index edd260a..1ae0547 100644 |
| --- a/docs/superpowers/specs/2026-09-07-slides-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-slides-design.md |
| @@ -80,6 +80,7 @@ calls. |
| | 9 | Themes are a **built-in set of token bundles** over estilo's variables. A deck may override accent colour and the two typefaces. No per-block fonts. | Free font picker per block (the fastest route to an ugly deck). | Paul approved the model 2026-09-07 | |
| | 10 | Bytes (images) live in the **platform object store**, referenced by `blobs.Ref`; never on the instance disk, never in a column beyond the 4 KiB inline rule. | SQLite blobs (the instance hibernates and its disk is ephemeral; rastrillo §2). | rastrillo rule, adopted | |
| | 11 | Repo `oficina/slides`, module `amadan.net/oficina/slides`, scaffolded by `rastrillo new` v0.26.0 (day theme, topbar shell). | Spanish name (`presentaciones`); the suite names generic apps in English. | Session, by the naming precedent | |
| +| 12 | `href` stored as `{href}`; marks named after Y attributes | plain-string href (does not survive y-prosemirror) | Session, 2026-09-07, from the Task 2 finding | |
| --- |
| @@ -172,11 +173,31 @@ phone, and the print engine never reads it. |
| ### 2.6 Text |
| Text is a ProseMirror document under a constrained schema: `paragraph` |
| -only at the block level; marks `bold`, `italic`, `code`, `link`, |
| -`highlight`. No nested blocks, no headings inside text (a heading is a |
| -block). Lists are `list` blocks whose paragraphs are items; a nested list |
| -is a second `list` block with `indent` 1. The same schema drives speaker |
| -notes and table cells. |
| +only at the block level; bold, italic, code, link and highlight as marks. |
| +No nested blocks, no headings inside text (a heading is a block). Lists |
| +are `list` blocks whose paragraphs are items; a nested list is a second |
| +`list` block with `indent` 1. The same schema drives speaker notes and |
| +table cells. |
| + |
| +Over Yjs, that document is an XmlFragment of `<paragraph>` elements, each |
| +holding one XmlText whose formatting attributes are the marks. **The |
| +ProseMirror marks are named `b`, `i`, `c`, `h`, `href` — the Y attribute |
| +names — and `href` is stored as the one-key map `{"href": url}`, not as a |
| +plain string** (ruling 12). Both follow from y-prosemirror's attribute |
| +mapping, which has no hook to override: it writes |
| +`attributes[mark.type.name] = mark.attrs` and reads |
| +`schema.mark(attrName, attrValue)`. So a mark's name must be the |
| +attribute name, and the mark `href` with attrs `{href}` is the attribute |
| +`href` carrying that object. A plain string there throws inside |
| +`schema.mark`, and y-prosemirror deletes the XmlText on a throw. Naming |
| +the marks this way makes the mapping the identity and keeps mapping code |
| +out of the editor. |
| + |
| +The writer is exact; the reader is tolerant, because updates reach the |
| +server from browsers as well. `b`, `i`, `c`, `h` are written `true` and |
| +read as set on any truthy value (y-prosemirror writes `{}` for an |
| +attr-less mark); `href` is written as the map and read as either the map |
| +or a plain string (documents written before this ruling). |
| ### 2.7 Assets |
diff --git a/fixtures/schema.mjs b/fixtures/schema.mjs| index 809061c..fa71012 100644 |
| --- a/fixtures/schema.mjs |
| +++ b/fixtures/schema.mjs |
| @@ -2,21 +2,21 @@ |
| // Schema the editor binds, and the Y structure the core reads and writes |
| // (core/src/schema.rs): an XmlFragment of <paragraph> elements, each |
| // holding one XmlText whose formatting attributes are the marks — b, i, |
| -// c, h with the value true, href with the URL as a plain string. |
| +// c, h with the value true, href with the one-key map {href: url}. |
| // |
| -// The ProseMirror marks are NAMED after those attributes on purpose. |
| -// y-prosemirror 1.3.7 has no mark mapping hook: sync-plugin.js writes |
| +// The ProseMirror marks are NAMED after those attributes, and href is |
| +// stored as a map, because of y-prosemirror 1.3.7's attribute mapping. |
| +// It has no mark mapping hook: sync-plugin.js writes |
| // `attrs[mark.type.name] = mark.attrs` and reads |
| // `schema.mark(attrName, attrValue)`. So a mark named `b` lands as the |
| -// attribute `b` (with value {} — which the core's reader takes as true), |
| -// and a core-written `b: true` comes back as the mark `b`. The `href` |
| -// mark is the exception: y-prosemirror writes its attrs object |
| -// ({href: url}), which the core ignores (it wants a string), and reading |
| -// the core's plain string would throw in schema.mark — and a throw there |
| -// makes y-prosemirror DELETE the Y.XmlText. The `href` attribute's |
| -// default of null is what keeps that path from destroying text; the |
| -// link is still lost. This is a known gap (task-2 report); writeText and |
| -// readText below follow the core, which is the contract. |
| +// attribute `b` with the value {} — which the core's reader takes as |
| +// true — and a core-written `b: true` comes back as the mark `b`; the |
| +// mark `href` with attrs {href} lands as the attribute `href` with that |
| +// object as its value, so the core writes the same map. A plain string |
| +// there would throw in schema.mark, and a throw makes y-prosemirror |
| +// DELETE the Y.XmlText. Reading is tolerant on both sides: href is |
| +// either the map or a plain string (documents written before this |
| +// shape), and any truthy value sets b/i/c/h. |
| import * as Y from 'yjs' |
| import { Schema } from 'prosemirror-model' |
| import { parse, run, pushRun } from './markdown.mjs' |
| @@ -54,10 +54,20 @@ const MARK_KEYS = ['b', 'i', 'c', 'h'] |
| export function runAttributes (r) { |
| const attrs = {} |
| for (const k of MARK_KEYS) if (r[k]) attrs[k] = true |
| - if (r.href != null) attrs.href = r.href |
| + if (r.href != null) attrs.href = { href: r.href } |
| return attrs |
| } |
| +/** |
| + * The URL an `href` attribute carries: the {href: url} map both sides |
| + * write, or the plain string documents written before it carry. |
| + */ |
| +export function hrefOf (v) { |
| + if (typeof v === 'string') return v |
| + if (v !== null && typeof v === 'object' && typeof v.href === 'string') return v.href |
| + return null |
| +} |
| + |
| /** One <paragraph> element holding one XmlText with the paragraph's runs. */ |
| export function paragraphElement (paragraph) { |
| const el = new Y.XmlElement('paragraph') |
| @@ -100,7 +110,7 @@ function pushRuns (text, out) { |
| i: truthy(a.i), |
| c: truthy(a.c), |
| h: truthy(a.h), |
| - href: typeof a.href === 'string' ? a.href : null |
| + href: hrefOf(a.href) |
| })) |
| } |
| } |
diff --git a/fixtures/schema.test.mjs b/fixtures/schema.test.mjs| new file mode 100644 |
| index 0000000..917f911 |
| --- /dev/null |
| +++ b/fixtures/schema.test.mjs |
| @@ -0,0 +1,105 @@ |
| +// The §2.6 text schema across the y-prosemirror bridge. schema.mjs and |
| +// core/src/schema.rs write the marks as the formatting attributes b, i, |
| +// c, h (true) and href ({href: url}) precisely so that y-prosemirror's |
| +// own attribute mapping — attrs[mark.type.name] = mark.attrs one way, |
| +// schema.mark(attrName, attrValue) the other — is the identity for this |
| +// schema, with no mapping code in the editor. These tests are the proof: |
| +// what the core writes, ProseMirror reads as the same marks; what |
| +// ProseMirror writes, the core's reader reads as the same runs. |
| +import test from 'node:test' |
| +import assert from 'node:assert/strict' |
| +import * as Y from 'yjs' |
| +import { prosemirrorToYXmlFragment, yXmlFragmentToProsemirrorJSON } from 'y-prosemirror' |
| +import { schema, textFragment, writeText, readText, docFromParagraphs } from './schema.mjs' |
| + |
| +// A paragraph with every mark the schema has, the link carrying bold. |
| +const paragraphs = [[ |
| + { t: 'plain ' }, |
| + { t: 'bold', b: true }, |
| + { t: ' ' }, |
| + { t: 'link', href: 'https://example.com/a?b=c' }, |
| + { t: ' ' }, |
| + { t: 'both', b: true, href: 'https://example.com/d' }, |
| + { t: ' ' }, |
| + { t: 'i', i: true }, |
| + { t: 'c', c: true }, |
| + { t: 'h', h: true } |
| +]] |
| + |
| +/** A Y.Doc holding `text`, written the way the core writes it. */ |
| +function written (text) { |
| + const doc = new Y.Doc({ guid: 'schema-test' }) |
| + doc.clientID = 1 |
| + const frag = doc.getXmlFragment('text') |
| + writeText(frag, text) |
| + return { doc, frag } |
| +} |
| + |
| +test('the core-written structure reads as ProseMirror marks', () => { |
| + const { frag } = written(paragraphs) |
| + const json = yXmlFragmentToProsemirrorJSON(frag) |
| + assert.equal(json.content.length, 1) |
| + // schema.mark(name, value) carries the attribute's value through as the |
| + // mark's attrs, so b/i/c/h come back with the writer's `true` — an |
| + // attr-less mark reads them as set either way. Only href's URL matters. |
| + const mark = m => (m.type === 'href' ? ['href', m.attrs.href] : [m.type]) |
| + const got = json.content[0].content.map(n => [n.text, (n.marks || []).map(mark)]) |
| + assert.deepEqual(got, [ |
| + ['plain ', []], |
| + ['bold', [['b']]], |
| + [' ', []], |
| + ['link', [['href', 'https://example.com/a?b=c']]], |
| + [' ', []], |
| + ['both', [['b'], ['href', 'https://example.com/d']]], |
| + [' ', []], |
| + ['i', [['i']]], |
| + ['c', [['c']]], |
| + ['h', [['h']]] |
| + ]) |
| +}) |
| + |
| +test('a y-prosemirror round trip preserves bold and the link', () => { |
| + // The core writes it; ProseMirror reads it; ProseMirror writes it back |
| + // over a fresh fragment; the core reads the same runs. |
| + const { frag } = written(paragraphs) |
| + const node = schema.nodeFromJSON(yXmlFragmentToProsemirrorJSON(frag)) |
| + |
| + const doc = new Y.Doc({ guid: 'schema-test-2' }) |
| + doc.clientID = 2 |
| + const out = doc.getXmlFragment('text') |
| + prosemirrorToYXmlFragment(node, out) |
| + |
| + assert.deepEqual(readText(out), paragraphs) |
| +}) |
| + |
| +test('y-prosemirror writes href as the one-key map and marks as {}', () => { |
| + // The shape the core's writer must match, straight from the plugin. |
| + const doc = new Y.Doc({ guid: 'schema-test-3' }) |
| + doc.clientID = 3 |
| + const out = doc.getXmlFragment('text') |
| + prosemirrorToYXmlFragment(docFromParagraphs([[{ t: 'x', b: true, href: 'https://y' }]]), out) |
| + // JSON round-tripped: the plugin builds null-prototype attribute objects. |
| + const delta = JSON.parse(JSON.stringify(out.toArray()[0].toArray()[0].toDelta())) |
| + assert.deepEqual(delta, [{ insert: 'x', attributes: { b: {}, href: { href: 'https://y' } } }]) |
| + assert.deepEqual(readText(out), [[{ t: 'x', b: true, href: 'https://y' }]]) |
| +}) |
| + |
| +test('a plain-string href, written before the map shape, still reads', () => { |
| + const doc = new Y.Doc({ guid: 'schema-test-4' }) |
| + doc.clientID = 4 |
| + const frag = doc.getXmlFragment('text') |
| + const el = new Y.XmlElement('paragraph') |
| + const text = new Y.XmlText() |
| + el.insert(0, [text]) |
| + frag.insert(0, [el]) |
| + text.applyDelta([{ insert: 'old', attributes: { href: 'https://old' } }]) |
| + assert.deepEqual(readText(frag), [[{ t: 'old', href: 'https://old' }]]) |
| +}) |
| + |
| +test('textFragment writes the same shape as writeText', () => { |
| + const doc = new Y.Doc({ guid: 'schema-test-5' }) |
| + doc.clientID = 5 |
| + const frag = doc.getXmlFragment('text') |
| + frag.insert(0, [textFragment(paragraphs)]) |
| + assert.deepEqual(readText(frag), paragraphs) |
| +}) |
diff --git a/fixtures/vectors/every-block.yjs b/fixtures/vectors/every-block.yjs| index 1083666..5911de5 100644 |
| Binary files a/fixtures/vectors/every-block.yjs and b/fixtures/vectors/every-block.yjs differ |
diff --git a/fixtures/vectors/title-only.yjs b/fixtures/vectors/title-only.yjs| index 69e41a1..1615f2a 100644 |
| Binary files a/fixtures/vectors/title-only.yjs and b/fixtures/vectors/title-only.yjs differ |
diff --git a/internal/slides/core/testdata/slides_core.wasm b/internal/slides/core/testdata/slides_core.wasm| index 76a31bd..2ff9114 100755 |
| Binary files a/internal/slides/core/testdata/slides_core.wasm and b/internal/slides/core/testdata/slides_core.wasm differ |