oficina / slides Public

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
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev f36ad1c56521e4a1e7d1237da663ffd11691c0c9 parent 88215d8
7 files changed, +276 −27
  • core/src/schema.rs +121 −8
  • docs/superpowers/specs/2026-09-07-slides-design.md +26 −5
  • fixtures/schema.mjs +24 −14
  • fixtures/schema.test.mjs +105 −0
  • fixtures/vectors/every-block.yjs binary
  • fixtures/vectors/title-only.yjs binary
  • internal/slides/core/testdata/slides_core.wasm binary

Intent

Human

<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>

Human

<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 &gt; snapshot.Seq`, matches. - `GET updates?since=0` answers the snapshot as the first frame with `Slides-Snapshot`, and the tail query is `seq &gt; 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 &lt;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