feat: fixtures toolchain, JS ops, Rust contract vectors
fixtures/ is the JS side of the deck contract: the §7.2 op vocabulary
over a Y.Doc (ops.mjs, mirroring core/src/ops.rs pass for pass), the
constrained ProseMirror schema and the core's text structure
(schema.mjs), the Markdown subset (markdown.mjs), the layout conversion
table (layout.mjs) and the JSON view (view.mjs). generate.mjs builds five
decks through applyOps and writes vectors/<name>.{yjs,view.json};
internal/slides/core/vectors_test.go loads each through the core and
compares views, and applies the same ops through the core's own Ops.
ProseMirror marks are named b, i, c, h, href: y-prosemirror 1.3.7 maps a
mark's name to the XmlText attribute name with no hook, so these are the
only names that bind to the core's attributes. href does not round-trip
(the core writes a string, y-prosemirror an object); see the task report.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
fixtures/.gitignore+1 −0fixtures/data/canvas.json+27 −0fixtures/data/columns.json+28 −0fixtures/data/every-block.json+41 −0fixtures/data/table.json+19 −0fixtures/data/title-only.json+13 −0fixtures/generate.mjs+58 −0fixtures/ids.mjs+17 −0fixtures/layout.mjs+107 −0fixtures/markdown.mjs+190 −0fixtures/ops-apply.mjs+274 −0fixtures/ops-model.mjs+222 −0fixtures/ops-parse.mjs+114 −0fixtures/ops.mjs+48 −0fixtures/ops.test.mjs+319 −0fixtures/package-lock.json+715 −0fixtures/package.json+27 −0fixtures/schema.mjs+147 −0fixtures/vectors/canvas.view.json+360 −0fixtures/vectors/canvas.yjsbinaryfixtures/vectors/columns.view.json+311 −0fixtures/vectors/columns.yjsbinaryfixtures/vectors/every-block.view.json+306 −0fixtures/vectors/every-block.yjsbinaryfixtures/vectors/table.view.json+156 −0fixtures/vectors/table.yjsbinaryfixtures/vectors/title-only.view.json+133 −0fixtures/vectors/title-only.yjsbinaryfixtures/view.mjs+70 −0fixtures/ydoc.mjs+110 −0internal/slides/core/vectors_test.go+186 −0
Intent
<task-notification> <task-id>a3e02c48c0ebe28ee</task-id> <tool-use-id>toolu_011A9ae75Advpn3ShB7Z7Enw</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a3e02c48c0ebe28ee.output</output-file> <status>completed</status> <summary>Agent "Slice 2 Task 1: live-sync server" 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>**Status:** done. `make ci` green; `go test -race` green. **Commit:** `055f75e` — feat: live-sync updates, stream, presence and snapshots (15 files, +2077/-38). No push. **Tests:** 11 new, all named tests from the brief present. `internal/slidestest/live_test.go` (round trip through a real second core doc, exclusive cursor, stream on connect + after post, presence to stream, membership + actor, 422/413, slide-HTML twin), `internal/slides/live/hub_test.go` (announce/expire with an injected clock, overflow drop, map growth), `internal/slides/snapshot_test.go` (201 updates → one snapshot at 200, tail-only replay, identical view with and without it). RED evidence captured at each of the three steps. **Notes:** `onUpdate` placeholder removed in favour of `a.live.PublishSeq`; the slide-HTML twin went in `editor.go` per the plan's file map; AGENTS.md's three stale seam paragraphs corrected (not in the brief's file list — flagged in the report). **Concerns:** no ABI-version column on `DeckSnapshot` (a core ABI bump would make old snapshots unreadable and 500 rather than fall back); no snapshot verification before writing; `readUpdates` buffers rather than streams; the 409 busy path and the stream's TTL/ping timings have no tests. **Report:** `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-2-editor/task-1-report.md`</result> <usage><subagent_tokens>184114</subagent_tokens><tool_uses>80</tool_uses><duration_ms>1020353</duration_ms></usage> </task-notification>
Attributed to this commit: 20.7 g CO₂e (2620371 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/fixtures/.gitignore b/fixtures/.gitignore| new file mode 100644 |
| index 0000000..c2658d7 |
| --- /dev/null |
| +++ b/fixtures/.gitignore |
| @@ -0,0 +1 @@ |
| +node_modules/ |
diff --git a/fixtures/data/canvas.json b/fixtures/data/canvas.json| new file mode 100644 |
| index 0000000..9c907ec |
| --- /dev/null |
| +++ b/fixtures/data/canvas.json |
| @@ -0,0 +1,27 @@ |
| +{ |
| + "guid": "canvas", |
| + "ops": [ |
| + {"op": "set_meta", "title": "Canvas", "theme": "ink"}, |
| + {"op": "register_asset", "id": "assetbg22222", "ref": "blobs/2026/09/bg.webp", "name": "bg.webp", "width": 1920, "height": 1080, "alt": ""}, |
| + {"op": "add_slide", "id": "sldcanvas222", "layout": "canvas"}, |
| + {"op": "set_slide", "id": "sldcanvas222", "background": {"kind": "image", "value": "assetbg22222"}, "phone": "reflow", "transition": "fade"}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkheading22", "type": "heading", "props": {"level": 1}, "markdown": "Placed by hand", "box": {"x": 0.1, "y": 0.12345, "w": 0.8, "h": 0.2}, "z": 5}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkdefault22", "type": "text", "markdown": "Box from the body region"}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkimage2222", "type": "image", "props": {"asset": "assetbg22222", "fit": "contain", "alt": ""}}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkshape2222", "type": "shape", "props": {"kind": "ellipse", "fill": "#f0f"}, "box": {"x": 0.7, "y": 0.7, "w": 0.2, "h": 0.2}}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkfirst2222", "type": "text", "after": null, "markdown": "Inserted first, z from index"}, |
| + {"op": "set_block", "slide": "sldcanvas222", "id": "blkdefault22", "box": {"x": 0.05, "y": 0.5, "w": 0.4, "h": 0.33333}, "z": 9}, |
| + {"op": "set_block", "slide": "sldcanvas222", "id": "blkshape2222", "z": 0}, |
| + {"op": "set_phone", "slide": "sldcanvas222", "id": "blkheading22", "phone": {"box": {"x": 0, "y": 0, "w": 1, "h": 0.25}, "size": "m"}}, |
| + {"op": "add_slide", "id": "sldtoflow222", "layout": "canvas", "after": "sldcanvas222"}, |
| + {"op": "add_block", "slide": "sldtoflow222", "id": "blklowhead22", "type": "heading", "props": {"level": 2}, "markdown": "Low heading", "box": {"x": 0.1, "y": 0.6, "w": 0.5, "h": 0.1}}, |
| + {"op": "add_block", "slide": "sldtoflow222", "id": "blktophead22", "type": "heading", "props": {"level": 1}, "markdown": "Top heading", "box": {"x": 0.1, "y": 0.05, "w": 0.5, "h": 0.1}}, |
| + {"op": "add_block", "slide": "sldtoflow222", "id": "blkright2222", "type": "text", "markdown": "Right", "box": {"x": 0.6, "y": 0.3, "w": 0.3, "h": 0.2}}, |
| + {"op": "add_block", "slide": "sldtoflow222", "id": "blkleft22222", "type": "text", "markdown": "Left", "box": {"x": 0.1, "y": 0.3, "w": 0.3, "h": 0.2}}, |
| + {"op": "add_block", "slide": "sldtoflow222", "id": "blkpicture22", "type": "image", "props": {"asset": "assetbg22222", "alt": "bg"}, "box": {"x": 0.6, "y": 0.6, "w": 0.3, "h": 0.3}}, |
| + {"op": "set_slide", "id": "sldtoflow222", "layout": "flow", "template": "two-column"}, |
| + {"op": "duplicate_slide", "id": "sldcanvas222", "new_id": "sldcopy22222", "new_block_ids": ["blkcopy22222", "blkcopy22223", "blkcopy22224", "blkcopy22225", "blkcopy22226"]}, |
| + {"op": "set_slide", "id": "sldcopy22222", "phone": "inherit"}, |
| + {"op": "set_text", "slide": "sldcopy22222", "id": "blkcopy22223", "markdown": "Edited on the copy only"} |
| + ] |
| +} |
diff --git a/fixtures/data/columns.json b/fixtures/data/columns.json| new file mode 100644 |
| index 0000000..f846352 |
| --- /dev/null |
| +++ b/fixtures/data/columns.json |
| @@ -0,0 +1,28 @@ |
| +{ |
| + "guid": "columns", |
| + "ops": [ |
| + {"op": "set_meta", "title": "Columns", "fonts": {"heading": "A", "body": "B"}}, |
| + {"op": "set_meta", "fonts": null}, |
| + {"op": "add_slide", "id": "sldcolumns22", "layout": "flow", "template": "two-column"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkheading22", "type": "heading", "props": {"level": 1}, "markdown": "Three up"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkleft22222", "type": "text", "markdown": "Left"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkmiddle222", "type": "list", "props": {"style": "bullet"}, "markdown": "- one\n- two"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkright2222", "type": "text", "markdown": "Right"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkaside2222", "type": "text", "region": "aside", "markdown": "Aside"}, |
| + {"op": "add_block", "slide": "sldcolumns22", "id": "blkcolumns22", "type": "columns", "props": {"count": 3, "children": ["blkleft22222", "blkmiddle222", "blkright2222"]}, "after": "blkheading22"}, |
| + {"op": "set_block", "slide": "sldcolumns22", "id": "blkcolumns22", "props": {"children": ["blkright2222", "blkmiddle222", "blkleft22222"]}}, |
| + {"op": "duplicate_slide", "id": "sldcolumns22", "new_id": "sldcopy22222", "new_block_ids": ["blkcopyhead2", "blkcopycols2", "blkcopyleft2", "blkcopymid22", "blkcopyright", "blkcopyaside"]}, |
| + {"op": "remove_block", "slide": "sldcopy22222", "id": "blkcopymid22"}, |
| + {"op": "set_block", "slide": "sldcopy22222", "id": "blkcopycols2", "props": {"count": 2}}, |
| + {"op": "set_slide", "id": "sldcolumns22", "layout": "canvas"}, |
| + {"op": "set_slide", "id": "sldcolumns22", "layout": "canvas"}, |
| + {"op": "add_slide", "id": "sldbothways2", "layout": "flow"}, |
| + {"op": "add_block", "slide": "sldbothways2", "id": "blkbwhead222", "type": "heading", "props": {"level": 1}, "markdown": "Both ways"}, |
| + {"op": "add_block", "slide": "sldbothways2", "id": "blkbwbody222", "type": "text", "markdown": "Body"}, |
| + {"op": "add_block", "slide": "sldbothways2", "id": "blkbwimage22", "type": "image", "props": {"alt": "x"}}, |
| + {"op": "set_slide", "id": "sldbothways2", "layout": "canvas"}, |
| + {"op": "set_slide", "id": "sldbothways2", "layout": "flow"}, |
| + {"op": "set_block", "slide": "sldcopy22222", "id": "blkcopycols2", "props": {"children": null}}, |
| + {"op": "move_block", "slide": "sldcopy22222", "id": "blkcopyaside", "after": "blkcopyhead2"} |
| + ] |
| +} |
diff --git a/fixtures/data/every-block.json b/fixtures/data/every-block.json| new file mode 100644 |
| index 0000000..8ac87da |
| --- /dev/null |
| +++ b/fixtures/data/every-block.json |
| @@ -0,0 +1,41 @@ |
| +{ |
| + "guid": "every-block", |
| + "ops": [ |
| + {"op": "set_meta", "title": "Every block"}, |
| + {"op": "register_asset", "id": "assetphoto22", "ref": "blobs/2026/09/photo.jpg", "name": "photo.jpg", "width": 1600, "height": 900, "alt": "A harbour at dusk"}, |
| + {"op": "register_asset", "id": "assetunused2", "ref": "blobs/2026/09/unused.png"}, |
| + {"op": "add_slide", "id": "sldscratch22", "layout": "flow"}, |
| + {"op": "add_slide", "id": "sldblocks222", "layout": "flow", "template": "title-body"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkheading22", "type": "heading", "props": {"level": 2}, "markdown": "Every block"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blktext22222", "type": "text", "props": {"size": "m"}, "markdown": "Plain **text** block"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blklist22222", "type": "list", "props": {"style": "number", "size": "s"}, "markdown": "1. First\n2. *Second*\n- Third"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkquote2222", "type": "quote", "props": {"cite": "Someone"}, "markdown": "==Quoted== words"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkimage2222", "type": "image", "props": {"asset": "assetphoto22", "fit": "cover", "alt": "A harbour at dusk", "radius": 8}}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkcoltext22", "type": "text", "props": {}, "markdown": "Left"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkcoltext23", "type": "text", "props": {}, "markdown": "Right"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkcolumns22", "type": "columns", "props": {"count": 2, "children": ["blkcoltext22", "blkcoltext23"]}}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkcode22222", "type": "code", "props": {"language": "go", "size": "s"}, "markdown": "func main() {\n\n\tfmt.Println(\"**not bold**\")\n}"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkshape2222", "type": "shape", "props": {"kind": "rect", "fill": "#eee", "stroke": "#333", "strokeWidth": 2, "radius": 4}, "region": "aside", "markdown": "Label"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkshapebare", "type": "shape", "props": {"kind": "line"}, "region": "aside"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blktable2222", "type": "table", "props": {"rows": 2, "cols": 3, "header": true}}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkdivider22", "type": "divider"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkspacer222", "type": "spacer", "props": {"size": "l"}}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkfirst2222", "type": "text", "after": null, "markdown": "Inserted first"}, |
| + {"op": "add_block", "slide": "sldblocks222", "id": "blkafter2222", "type": "text", "after": "blkheading22", "markdown": "Inserted after the heading"}, |
| + {"op": "set_text", "slide": "sldblocks222", "id": "blktext22222", "markdown": "Replaced *text* with [a link](https://example.org)"}, |
| + {"op": "set_block", "slide": "sldblocks222", "id": "blkheading22", "props": {"level": 3, "tmp": "gone"}, "region": "body"}, |
| + {"op": "set_block", "slide": "sldblocks222", "id": "blkheading22", "props": {"tmp": null}}, |
| + {"op": "set_phone", "slide": "sldblocks222", "id": "blkimage2222", "phone": {"hidden": true}}, |
| + {"op": "set_phone", "slide": "sldblocks222", "id": "blktext22222", "phone": {"order": -1, "size": "s"}}, |
| + {"op": "set_phone", "slide": "sldblocks222", "id": "blktext22222", "phone": {"size": "l"}}, |
| + {"op": "move_block", "slide": "sldblocks222", "id": "blkspacer222", "after": null}, |
| + {"op": "move_block", "slide": "sldblocks222", "id": "blkfirst2222", "after": "blkdivider22"}, |
| + {"op": "remove_block", "slide": "sldblocks222", "id": "blkcoltext23"}, |
| + {"op": "remove_block", "slide": "sldblocks222", "id": "blkshapebare"}, |
| + {"op": "move_slide", "id": "sldblocks222", "after": null}, |
| + {"op": "set_slide", "id": "sldscratch22", "template": "section", "transition": "fade"}, |
| + {"op": "move_slide", "id": "sldscratch22", "after": "sldblocks222"}, |
| + {"op": "remove_slide", "id": "sldscratch22"}, |
| + {"op": "remove_asset", "id": "assetunused2"} |
| + ] |
| +} |
diff --git a/fixtures/data/table.json b/fixtures/data/table.json| new file mode 100644 |
| index 0000000..b2a8b79 |
| --- /dev/null |
| +++ b/fixtures/data/table.json |
| @@ -0,0 +1,19 @@ |
| +{ |
| + "guid": "table", |
| + "ops": [ |
| + {"op": "set_meta", "title": "Tables"}, |
| + {"op": "add_slide", "id": "sldtables222", "layout": "flow", "template": "title-body"}, |
| + {"op": "add_block", "slide": "sldtables222", "id": "blkdefault22", "type": "table"}, |
| + {"op": "add_block", "slide": "sldtables222", "id": "blksized2222", "type": "table", "props": {"rows": 3, "cols": 2, "header": true}}, |
| + {"op": "set_block", "slide": "sldtables222", "id": "blksized2222", "props": {"rows": 4}}, |
| + {"op": "set_block", "slide": "sldtables222", "id": "blksized2222", "props": {"cols": 1, "header": false}}, |
| + {"op": "set_block", "slide": "sldtables222", "id": "blkdefault22", "props": {"rows": 1, "cols": 1}}, |
| + {"op": "set_block", "slide": "sldtables222", "id": "blkdefault22", "props": {"rows": 2}}, |
| + {"op": "add_slide", "id": "sldcanvas222", "layout": "canvas", "after": null}, |
| + {"op": "add_block", "slide": "sldcanvas222", "id": "blkcanvastab", "type": "table", "props": {"rows": 1, "cols": 3}, "box": {"x": 0.1, "y": 0.1, "w": 0.8, "h": 0.3}}, |
| + {"op": "duplicate_slide", "id": "sldtables222", "new_id": "sldcopy22222", "new_block_ids": ["blkcopydef22", "blkcopysized"]}, |
| + {"op": "set_block", "slide": "sldcopy22222", "id": "blkcopysized", "props": {"rows": 2, "cols": 2}}, |
| + {"op": "set_slide", "id": "sldcopy22222", "layout": "canvas"}, |
| + {"op": "set_notes", "id": "sldcopy22222", "markdown": " \n\n "} |
| + ] |
| +} |
diff --git a/fixtures/data/title-only.json b/fixtures/data/title-only.json| new file mode 100644 |
| index 0000000..aa8fca8 |
| --- /dev/null |
| +++ b/fixtures/data/title-only.json |
| @@ -0,0 +1,13 @@ |
| +{ |
| + "guid": "title-only", |
| + "ops": [ |
| + {"op": "set_meta", "title": "Quarterly review", "theme": "paper", "accent": "#0a7", "fonts": {"heading": "Fraunces", "body": "Inter"}}, |
| + {"op": "add_slide", "id": "sldtitle2222", "layout": "flow", "template": "title"}, |
| + {"op": "add_block", "slide": "sldtitle2222", "id": "blkheading22", "type": "heading", "props": {"level": 1}, "region": "heading", |
| + "markdown": "**Bold** *italic* `code` ==mark== [link](https://example.com/a?b=1) \\* plain"}, |
| + {"op": "add_block", "slide": "sldtitle2222", "id": "blksubtitle2", "type": "text", "props": {"size": "l", "align": "center"}, |
| + "markdown": "A line\nthat wraps\n\nSecond paragraph with [**bold link**](https://example.com)"}, |
| + {"op": "set_notes", "id": "sldtitle2222", "markdown": "Say hello.\n\n- Thank the team\n- Mention ==the number==\n1. Then pause"}, |
| + {"op": "set_meta", "accent": null} |
| + ] |
| +} |
diff --git a/fixtures/generate.mjs b/fixtures/generate.mjs| new file mode 100644 |
| index 0000000..98404dd |
| --- /dev/null |
| +++ b/fixtures/generate.mjs |
| @@ -0,0 +1,58 @@ |
| +// Generates the vectors that hold the Rust core to this module's result |
| +// (spec §2.8, Docs' rule adopted unchanged). For each data/<name>.json — |
| +// {guid, ops} — a fresh Y.Doc with that guid and clientID 1 takes the |
| +// ops through applyOps; vectors/<name>.yjs is the whole state as a v1 |
| +// update and vectors/<name>.view.json is this side's view of it. |
| +// internal/slides/core/vectors_test.go loads each .yjs through the core |
| +// and compares views, and applies the same ops through the core's own |
| +// Ops and compares again. |
| +// |
| +// These are the contract: never edit a vector to make a test pass. A |
| +// deliberate rule change regenerates them in the same commit. The ids |
| +// come from the data files so a run is the same bytes every time. |
| +import * as Y from 'yjs' |
| +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' |
| +import { join, dirname } from 'node:path' |
| +import { fileURLToPath } from 'node:url' |
| +import { applyOps, view } from './ops.mjs' |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)) |
| +export const dataDir = join(here, 'data') |
| +export const vectorsDir = join(here, 'vectors') |
| + |
| +/** JSON with every object's keys sorted, pretty-printed, newline-terminated. */ |
| +export function sortedJSON (v) { |
| + const sort = x => { |
| + if (Array.isArray(x)) return x.map(sort) |
| + if (x !== null && typeof x === 'object') { |
| + const out = {} |
| + for (const k of Object.keys(x).sort()) out[k] = sort(x[k]) |
| + return out |
| + } |
| + return x |
| + } |
| + return JSON.stringify(sort(v), null, 2) + '\n' |
| +} |
| + |
| +/** The update and view for one data file's ops. */ |
| +export function buildVector (name, data) { |
| + if (data.guid !== name) throw new Error(`${name}.json: guid ${JSON.stringify(data.guid)} must equal the file name`) |
| + const doc = new Y.Doc({ guid: data.guid }) |
| + doc.clientID = 1 |
| + applyOps(doc, data.ops) |
| + return { update: Y.encodeStateAsUpdate(doc), view: view(doc) } |
| +} |
| + |
| +export function generate () { |
| + mkdirSync(vectorsDir, { recursive: true }) |
| + const names = readdirSync(dataDir).filter(f => f.endsWith('.json')).map(f => f.slice(0, -5)).sort() |
| + for (const name of names) { |
| + const data = JSON.parse(readFileSync(join(dataDir, `${name}.json`), 'utf8')) |
| + const { update, view: v } = buildVector(name, data) |
| + writeFileSync(join(vectorsDir, `${name}.yjs`), Buffer.from(update)) |
| + writeFileSync(join(vectorsDir, `${name}.view.json`), sortedJSON(v)) |
| + console.log(`${name}: ${data.ops.length} ops, ${update.length} bytes`) |
| + } |
| +} |
| + |
| +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) generate() |
diff --git a/fixtures/ids.mjs b/fixtures/ids.mjs| new file mode 100644 |
| index 0000000..c7fbba0 |
| --- /dev/null |
| +++ b/fixtures/ids.mjs |
| @@ -0,0 +1,17 @@ |
| +// Slide, block and asset ids (spec §2.2): 12 characters of lowercase |
| +// base32, drawn from the platform's CSPRNG. The browser mints an id |
| +// before it calls applyOps, because the core never draws entropy and an |
| +// add_slide / add_block / duplicate_slide without ids is refused. |
| +// generate.mjs never calls this: a vector's ids come from its data file |
| +// so the vector is the same bytes every run. |
| + |
| +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567' |
| +export const ID_LENGTH = 12 |
| + |
| +export function newID () { |
| + const bytes = new Uint8Array(ID_LENGTH) |
| + globalThis.crypto.getRandomValues(bytes) |
| + let out = '' |
| + for (const b of bytes) out += ALPHABET[b & 31] |
| + return out |
| +} |
diff --git a/fixtures/layout.mjs b/fixtures/layout.mjs| new file mode 100644 |
| index 0000000..e0ee026 |
| --- /dev/null |
| +++ b/fixtures/layout.mjs |
| @@ -0,0 +1,107 @@ |
| +// Layout conversion between a slide's two modes (spec §2.3) — |
| +// core/src/layout.rs, table for table. A flow slide places blocks by |
| +// region; a canvas slide by an absolute box and z. Switching carries the |
| +// blocks across, lossily in both directions. |
| +import * as Y from 'yjs' |
| +import { getString, getObject, getArray, mapsOf, cloneMap } from './ydoc.mjs' |
| + |
| +export const REGIONS = ['heading', 'body', 'aside', 'media'] |
| + |
| +/** Rounds a box coordinate to the four decimals §2.3 specifies (Rust's round(): half away from zero). */ |
| +export function round4 (f) { |
| + const scaled = f * 10000 |
| + return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 10000 |
| +} |
| + |
| +export function boxJSON (x, y, w, h) { |
| + return { x: round4(x), y: round4(y), w: round4(w), h: round4(h) } |
| +} |
| + |
| +/** The region a block lands in when nobody said. */ |
| +export function defaultRegion (kind) { |
| + if (kind === 'heading') return 'heading' |
| + if (kind === 'image') return 'media' |
| + return 'body' |
| +} |
| + |
| +/** The frame fraction each region occupies: [x, y, w, h]. body narrows beside an aside. */ |
| +export function regionRect (region, hasAside) { |
| + switch (region) { |
| + case 'heading': return [0.06, 0.08, 0.88, 0.18] |
| + case 'aside': return [0.54, 0.30, 0.40, 0.62] |
| + case 'media': return [0.50, 0.08, 0.44, 0.84] |
| + default: return hasAside ? [0.06, 0.30, 0.44, 0.62] : [0.06, 0.30, 0.88, 0.62] |
| + } |
| +} |
| + |
| +function regionOf (block) { |
| + const r = getString(block, 'region') |
| + if (r !== null && REGIONS.includes(r)) return r |
| + return defaultRegion(getString(block, 'type') ?? '') |
| +} |
| + |
| +/** The slide's block array, created if a peer wrote a slide without one. */ |
| +export function blocksOf (slide) { |
| + let a = getArray(slide, 'blocks') |
| + if (a === null) { |
| + a = new Y.Array() |
| + slide.set('blocks', a) |
| + } |
| + return a |
| +} |
| + |
| +/** |
| + * Flow to canvas: every block gets the box its region gave it, blocks |
| + * sharing a region split its height equally in order, z is the array |
| + * index, and region goes. |
| + */ |
| +export function toCanvas (slide) { |
| + const maps = mapsOf(blocksOf(slide)) |
| + const regions = maps.map(([, m]) => regionOf(m)) |
| + const hasAside = regions.includes('aside') |
| + const counts = new Map() |
| + for (const r of regions) counts.set(r, (counts.get(r) ?? 0) + 1) |
| + const seen = new Map() |
| + maps.forEach(([i, m], at) => { |
| + const region = regions[at] |
| + const n = counts.get(region) |
| + const k = seen.get(region) ?? 0 |
| + const [x, y, w, h] = regionRect(region, hasAside) |
| + const slot = h / n |
| + seen.set(region, k + 1) |
| + m.set('box', boxJSON(x, y + slot * k, w, slot)) |
| + m.set('z', i) |
| + m.delete('region') |
| + }) |
| +} |
| + |
| +/** |
| + * Canvas to flow: boxes go, regions come from position (a heading in |
| + * the top quarter is heading, an image is media, the rest body), and |
| + * the array is reordered top to bottom, left to right. |
| + */ |
| +export function toFlow (slide) { |
| + const blocks = blocksOf(slide) |
| + const maps = mapsOf(blocks) |
| + const order = [] |
| + maps.forEach(([, m], pos) => { |
| + const kind = getString(m, 'type') ?? '' |
| + const bx = getObject(m, 'box') |
| + const coord = (k, dflt) => (bx !== null && typeof bx[k] === 'number' ? bx[k] : dflt) |
| + const x = coord('x', 0.06) |
| + const y = coord('y', 0.30) |
| + let region = 'body' |
| + if (kind === 'heading' && y < 0.25) region = 'heading' |
| + else if (kind === 'image') region = 'media' |
| + m.set('region', region) |
| + m.delete('box') |
| + m.delete('z') |
| + order.push([y, x, pos]) |
| + }) |
| + order.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1])) |
| + if (order.every(([, , pos], i) => i === pos)) return |
| + const copies = order.map(([, , pos]) => cloneMap(maps[pos][1])) |
| + blocks.delete(0, blocks.length) |
| + blocks.insert(0, copies) |
| +} |
| + |
diff --git a/fixtures/markdown.mjs b/fixtures/markdown.mjs| new file mode 100644 |
| index 0000000..ef1e5e7 |
| --- /dev/null |
| +++ b/fixtures/markdown.mjs |
| @@ -0,0 +1,190 @@ |
| +// The Markdown subset the operations accept text in — core/src/markdown.rs |
| +// line for line, because a batch applied here and the same batch applied |
| +// through the core must produce the same runs. |
| +// |
| +// - Paragraphs split on blank lines; the lines of one paragraph join with |
| +// a space. |
| +// - A line starting `- ` or `1. ` (any digits) is its own paragraph with |
| +// the marker stripped. |
| +// - Inline: **bold**, *italic*, `code`, ==highlight==, [text](url). A |
| +// delimiter with no closing partner is literal text. Inside a code span |
| +// nothing else is parsed. A link's text may carry marks; a link inside a |
| +// link is literal. |
| +// - The only escape is \*, which yields a literal *. |
| +// |
| +// A run is {t, b?, i?, c?, h?, href?}: the view's run shape, false and |
| +// absent marks omitted. parseLines is the plain variant for code blocks. |
| + |
| +export function sameMarks (a, b) { |
| + return !!a.b === !!b.b && !!a.i === !!b.i && !!a.c === !!b.c && !!a.h === !!b.h && |
| + (a.href ?? null) === (b.href ?? null) |
| +} |
| + |
| +/** A run with only the marks that are on, so deepEqual sees the view shape. */ |
| +export function run (t, marks = {}) { |
| + const r = { t } |
| + if (marks.b) r.b = true |
| + if (marks.i) r.i = true |
| + if (marks.c) r.c = true |
| + if (marks.h) r.h = true |
| + if (marks.href != null) r.href = marks.href |
| + return r |
| +} |
| + |
| +/** Appends a run to a paragraph, merging into the last one when the marks match. */ |
| +export function pushRun (paragraph, r) { |
| + const last = paragraph[paragraph.length - 1] |
| + if (last && sameMarks(last, r)) last.t += r.t |
| + else paragraph.push(r) |
| +} |
| + |
| +/** Rust's str::lines(): split on \n or \r\n, the final line ending optional, no phantom last line. */ |
| +export function lines (input) { |
| + const out = [] |
| + let start = 0 |
| + while (start < input.length) { |
| + const nl = input.indexOf('\n', start) |
| + if (nl === -1) { out.push(input.slice(start)); break } |
| + const line = input.slice(start, nl) |
| + out.push(line.endsWith('\r') ? line.slice(0, -1) : line) |
| + start = nl + 1 |
| + } |
| + return out |
| +} |
| + |
| +export function parse (input) { |
| + const sources = [] |
| + let current = null |
| + for (let line of lines(input)) { |
| + line = line.trim() |
| + if (line === '') { |
| + if (current !== null) { sources.push(current); current = null } |
| + continue |
| + } |
| + const item = listItem(line) |
| + if (item !== null) { |
| + if (current !== null) sources.push(current) |
| + current = item |
| + continue |
| + } |
| + current = current === null ? line : current + ' ' + line |
| + } |
| + if (current !== null) sources.push(current) |
| + return sources.map(parseInline) |
| +} |
| + |
| +export function parseLines (input) { |
| + return lines(input).map(line => (line === '' ? [] : [run(line)])) |
| +} |
| + |
| +function listItem (line) { |
| + if (line.startsWith('- ')) return line.slice(2).trimStart() |
| + let digits = 0 |
| + while (digits < line.length && line.charCodeAt(digits) >= 48 && line.charCodeAt(digits) <= 57) digits++ |
| + if (digits > 0 && line.startsWith('. ', digits)) return line.slice(digits + 2).trimStart() |
| + return null |
| +} |
| + |
| +class Parser { |
| + constructor () { |
| + this.runs = [] |
| + this.buf = '' |
| + this.marks = { b: false, i: false, c: false, h: false, href: null } |
| + } |
| + |
| + flush () { |
| + if (this.buf === '') return |
| + pushRun(this.runs, run(this.buf, this.marks)) |
| + this.buf = '' |
| + } |
| + |
| + toggle (delim, rest, key) { |
| + if (this.marks[key]) { |
| + this.flush() |
| + this.marks[key] = false |
| + return true |
| + } |
| + if (hasPartner(rest, delim)) { |
| + this.flush() |
| + this.marks[key] = true |
| + return true |
| + } |
| + return false |
| + } |
| + |
| + parse (src) { |
| + let i = 0 |
| + while (i < src.length) { |
| + const rest = src.slice(i) |
| + if (rest.startsWith('\\*')) { this.buf += '*'; i += 2; continue } |
| + if (rest.startsWith('**')) { |
| + if (this.toggle('**', rest.slice(2), 'b')) { i += 2; continue } |
| + } else if (rest.startsWith('==')) { |
| + if (this.toggle('==', rest.slice(2), 'h')) { i += 2; continue } |
| + } else if (rest.startsWith('*')) { |
| + if (this.toggle('*', rest.slice(1), 'i')) { i += 1; continue } |
| + } else if (rest.startsWith('`')) { |
| + const end = rest.indexOf('`', 1) |
| + if (end !== -1) { |
| + this.flush() |
| + const was = this.marks.c |
| + this.marks.c = true |
| + this.buf += rest.slice(1, end) |
| + this.flush() |
| + this.marks.c = was |
| + i += end + 1 |
| + continue |
| + } |
| + } else if (rest.startsWith('[') && this.marks.href === null) { |
| + const l = link(rest) |
| + if (l !== null) { |
| + this.flush() |
| + const outer = { ...this.marks } |
| + this.marks.href = l.url |
| + this.parse(l.text) |
| + this.flush() |
| + this.marks = outer |
| + i += l.len |
| + continue |
| + } |
| + } |
| + const ch = String.fromCodePoint(rest.codePointAt(0)) |
| + this.buf += ch |
| + i += ch.length |
| + } |
| + } |
| +} |
| + |
| +/** Whether delim occurs in rest as itself: for a lone *, one that is not half of a **. */ |
| +function hasPartner (rest, delim) { |
| + if (delim !== '*') return rest.includes(delim) |
| + let i = 0 |
| + while (i < rest.length) { |
| + if (rest[i] === '*') { |
| + if (rest[i + 1] === '*') { i += 2; continue } |
| + return true |
| + } |
| + i++ |
| + } |
| + return false |
| +} |
| + |
| +/** [text](url) at the start of s: the text, the url, and the length the whole link spans. */ |
| +function link (s) { |
| + const close = s.indexOf('](') |
| + if (close === -1) return null |
| + const text = s.slice(1, close) |
| + const urlStart = close + 2 |
| + const end = s.indexOf(')', urlStart) |
| + if (end === -1) return null |
| + const url = s.slice(urlStart, end) |
| + if (text === '' || url === '') return null |
| + return { text, url, len: end + 1 } |
| +} |
| + |
| +function parseInline (src) { |
| + const p = new Parser() |
| + p.parse(src) |
| + p.flush() |
| + return p.runs |
| +} |
diff --git a/fixtures/ops-apply.mjs b/fixtures/ops-apply.mjs| new file mode 100644 |
| index 0000000..54f1948 |
| --- /dev/null |
| +++ b/fixtures/ops-apply.mjs |
| @@ -0,0 +1,274 @@ |
| +// The second pass of applyOps — core/src/ops.rs's apply_one, and the |
| +// constructors it uses from schema.rs. Runs inside one transaction after |
| +// the model has passed every op, so by construction every lookup here |
| +// succeeds; the throws that remain surface a bug as a message rather |
| +// than a half-applied batch going unnoticed. |
| +// |
| +// What a slide and a block look like in the document is decided here |
| +// and in the core together (schema.rs's module comment): a block map |
| +// holds id, type, props (Y.Map of plain values), phone (Y.Map), then |
| +// region (flow) or box + z (canvas); text-bearing types carry text (an |
| +// XmlFragment), a table carries cells (a Y.Array of XmlFragments). |
| +import * as Y from 'yjs' |
| +import { getString, getNumber, getMap, getArray, getFragment, findById, mapsOf, mapToJSON, cloneMap } from './ydoc.mjs' |
| +import { writeText, textFragment } from './schema.mjs' |
| +import { parse, parseLines } from './markdown.mjs' |
| +import { defaultRegion, regionRect, boxJSON, blocksOf, toCanvas, toFlow } from './layout.mjs' |
| +import { isTextType, tableSide } from './ops-model.mjs' |
| + |
| +export const DEFAULT_TEMPLATE = 'blank' |
| +export const DEFAULT_TABLE_ROWS = 2 |
| +export const DEFAULT_TABLE_COLS = 2 |
| + |
| +const fail = msg => { throw new Error(msg) } |
| + |
| +function slideMap (r, id) { |
| + return findById(r.slides, id) ?? fail(`no slide ${JSON.stringify(id)}`) |
| +} |
| +function blockMap (blocks, id) { |
| + return findById(blocks, id) ?? fail(`no block ${JSON.stringify(id)}`) |
| +} |
| + |
| +/** Where an after puts a new entry: absent appends, null goes first, an id goes right after it. */ |
| +function insertIndex (array, after) { |
| + if (after === undefined) return array.length |
| + if (after === null) return 0 |
| + return (findById(array, after) ?? fail(`no entry ${JSON.stringify(after)}`))[0] + 1 |
| +} |
| + |
| +function fragmentOf (map, key) { |
| + let f = getFragment(map, key) |
| + if (f === null) { f = new Y.XmlFragment(); map.set(key, f) } |
| + return f |
| +} |
| +function mapOf (map, key) { |
| + let m = getMap(map, key) |
| + if (m === null) { m = new Y.Map(); map.set(key, m) } |
| + return m |
| +} |
| + |
| +/** Text for a block of kind from its Markdown: code is plain lines, everything else the subset. */ |
| +const textFor = (kind, markdown) => (kind === 'code' ? parseLines(markdown) : parse(markdown)) |
| + |
| +function plainMap (entries) { |
| + const m = new Y.Map() |
| + for (const k of Object.keys(entries)) m.set(k, entries[k]) |
| + return m |
| +} |
| + |
| +function backgroundMap (kind, value) { |
| + return plainMap({ kind, value }) |
| +} |
| + |
| +/** A block map with a fresh text fragment / cells array where the type wants one (schema.rs block_prelim). */ |
| +function blockPrelim ({ id, kind, props, region, box, z, text, cells }) { |
| + const m = new Y.Map() |
| + m.set('id', id) |
| + m.set('type', kind) |
| + m.set('props', plainMap(props)) |
| + m.set('phone', new Y.Map()) |
| + if (region !== undefined) m.set('region', region) |
| + if (box !== undefined) m.set('box', box) |
| + if (z !== undefined) m.set('z', z) |
| + if (text !== undefined) m.set('text', textFragment(text)) |
| + if (cells !== undefined) { |
| + const a = new Y.Array() |
| + m.set('cells', a) |
| + a.insert(0, Array.from({ length: cells }, () => new Y.XmlFragment())) |
| + } |
| + return m |
| +} |
| + |
| +function slidePrelim (id, layout, template) { |
| + const m = new Y.Map() |
| + m.set('id', id) |
| + m.set('layout', layout) |
| + m.set('template', template) |
| + m.set('phone', 'inherit') |
| + m.set('transition', 'none') |
| + m.set('background', backgroundMap('theme', '')) |
| + m.set('notes', new Y.XmlFragment()) |
| + m.set('blocks', new Y.Array()) |
| + return m |
| +} |
| + |
| +/** Drops removedId from every columns block's props.children in blocks. */ |
| +function removeFromColumnsChildren (blocks, removedId) { |
| + for (const [, m] of mapsOf(blocks)) { |
| + if (getString(m, 'type') !== 'columns') continue |
| + const pm = getMap(m, 'props') |
| + if (pm === null) continue |
| + const children = mapToJSON(pm).children |
| + if (!Array.isArray(children) || !children.includes(removedId)) continue |
| + pm.set('children', children.filter(c => c !== removedId)) |
| + } |
| +} |
| + |
| +/** Gives a copied slide its new ids: the slide's own, each block's, and any columns.children naming the old ones. */ |
| +function rewriteIds (copy, newId, newBlockIds) { |
| + copy.set('id', newId) |
| + const blocks = getArray(copy, 'blocks') |
| + if (blocks === null) return |
| + const mapping = new Map() |
| + const maps = mapsOf(blocks) |
| + maps.forEach(([, bm], k) => { |
| + if (k >= newBlockIds.length) return |
| + const old = getString(bm, 'id') |
| + if (old !== null) mapping.set(old, newBlockIds[k]) |
| + bm.set('id', newBlockIds[k]) |
| + }) |
| + for (const [, bm] of maps) { |
| + const props = getMap(bm, 'props') |
| + if (props === null) continue |
| + const children = props.get('children') |
| + if (!Array.isArray(children)) continue |
| + props.set('children', children.map(c => (typeof c === 'string' && mapping.has(c) ? mapping.get(c) : c))) |
| + } |
| +} |
| + |
| +function setMeta (r, op) { |
| + if (op.title !== undefined) r.meta.set('title', op.title) |
| + if (op.theme !== undefined) r.meta.set('theme', op.theme) |
| + if (op.accent === null) r.meta.delete('accent') |
| + else if (op.accent !== undefined) r.meta.set('accent', op.accent) |
| + if (op.fonts === null) r.meta.delete('fonts') |
| + else if (op.fonts !== undefined) r.meta.set('fonts', { heading: op.fonts.heading, body: op.fonts.body }) |
| +} |
| + |
| +function addBlock (r, op) { |
| + const [, s] = slideMap(r, op.slide) |
| + const blocks = blocksOf(s) |
| + const at = insertIndex(blocks, op.after) |
| + const canvas = getString(s, 'layout') === 'canvas' |
| + const props = { ...op.props } |
| + let cells |
| + if (op.type === 'table') { |
| + const rows = tableSide(props, 'rows') ?? DEFAULT_TABLE_ROWS |
| + const cols = tableSide(props, 'cols') ?? DEFAULT_TABLE_COLS |
| + props.rows = rows |
| + props.cols = cols |
| + cells = rows * cols |
| + } |
| + const text = isTextType(op.type) ? (op.markdown === undefined ? [] : textFor(op.type, op.markdown)) : undefined |
| + const region = op.region ?? defaultRegion(op.type) |
| + const spec = { id: op.id, kind: op.type, props, text, cells } |
| + if (canvas) { |
| + spec.box = op.box !== undefined ? boxJSON(op.box.x, op.box.y, op.box.w, op.box.h) : boxJSON(...regionRect(region, false)) |
| + spec.z = op.z ?? at |
| + } else { |
| + spec.region = region |
| + } |
| + blocks.insert(at, [blockPrelim(spec)]) |
| +} |
| + |
| +function setBlock (r, op) { |
| + const [, s] = slideMap(r, op.slide) |
| + const blocks = blocksOf(s) |
| + const [, m] = blockMap(blocks, op.id) |
| + if (op.props !== undefined) { |
| + const pm = mapOf(m, 'props') |
| + for (const k of Object.keys(op.props)) { |
| + if (op.props[k] === null) pm.delete(k) |
| + else pm.set(k, op.props[k]) |
| + } |
| + const isTable = getString(m, 'type') === 'table' |
| + // cells is row-major and flat, so a size change only appends to or |
| + // truncates the end of it (spec §2.4's v1 limitation). |
| + if (isTable && ('rows' in op.props || 'cols' in op.props)) { |
| + const rows = Math.trunc(getNumber(pm, 'rows') ?? DEFAULT_TABLE_ROWS) |
| + const cols = Math.trunc(getNumber(pm, 'cols') ?? DEFAULT_TABLE_COLS) |
| + const want = Math.max(rows, 1) * Math.max(cols, 1) |
| + let cells = getArray(m, 'cells') |
| + if (cells === null) { cells = new Y.Array(); m.set('cells', cells) } |
| + const have = cells.length |
| + if (have > want) cells.delete(want, have - want) |
| + if (have < want) cells.insert(have, Array.from({ length: want - have }, () => new Y.XmlFragment())) |
| + } |
| + } |
| + if (op.region === null) m.delete('region') |
| + else if (op.region !== undefined) m.set('region', op.region) |
| + if (op.box === null) m.delete('box') |
| + else if (op.box !== undefined) m.set('box', boxJSON(op.box.x, op.box.y, op.box.w, op.box.h)) |
| + if (op.z !== undefined) m.set('z', op.z) |
| +} |
| + |
| +function setPhone (r, op) { |
| + const [, s] = slideMap(r, op.slide) |
| + const [, m] = blockMap(blocksOf(s), op.id) |
| + const pm = mapOf(m, 'phone') |
| + pm.clear() |
| + const p = op.phone |
| + if (p.hidden !== undefined) pm.set('hidden', p.hidden) |
| + if (p.order !== undefined) pm.set('order', p.order) |
| + if (p.size !== undefined) pm.set('size', p.size) |
| + if (p.box !== undefined) pm.set('box', boxJSON(p.box.x, p.box.y, p.box.w, p.box.h)) |
| +} |
| + |
| +export function applyOne (r, op) { |
| + switch (op.op) { |
| + case 'set_meta': return setMeta(r, op) |
| + case 'add_slide': |
| + return r.slides.insert(insertIndex(r.slides, op.after), [slidePrelim(op.id, op.layout, op.template ?? DEFAULT_TEMPLATE)]) |
| + case 'remove_slide': |
| + return r.slides.delete(slideMap(r, op.id)[0], 1) |
| + case 'move_slide': { |
| + const [i, m] = slideMap(r, op.id) |
| + const copy = cloneMap(m) |
| + r.slides.delete(i, 1) |
| + return r.slides.insert(insertIndex(r.slides, op.after ?? null), [copy]) |
| + } |
| + case 'duplicate_slide': { |
| + const [i, m] = slideMap(r, op.id) |
| + const copy = cloneMap(m) |
| + r.slides.insert(i + 1, [copy]) |
| + return rewriteIds(copy, op.new_id, op.new_block_ids) |
| + } |
| + case 'set_slide': { |
| + const [, m] = slideMap(r, op.id) |
| + if (op.layout !== undefined) { |
| + const current = getString(m, 'layout') ?? 'flow' |
| + if (op.layout !== current) { |
| + if (op.layout === 'canvas') toCanvas(m) |
| + else toFlow(m) |
| + m.set('layout', op.layout) |
| + } |
| + } |
| + if (op.template !== undefined) m.set('template', op.template) |
| + if (op.background !== undefined) m.set('background', backgroundMap(op.background.kind, op.background.value)) |
| + if (op.phone !== undefined) m.set('phone', op.phone) |
| + if (op.transition !== undefined) m.set('transition', op.transition) |
| + return |
| + } |
| + case 'set_notes': { |
| + const [, m] = slideMap(r, op.id) |
| + return writeText(fragmentOf(m, 'notes'), parse(op.markdown)) |
| + } |
| + case 'add_block': return addBlock(r, op) |
| + case 'remove_block': { |
| + const [, s] = slideMap(r, op.slide) |
| + const blocks = blocksOf(s) |
| + blocks.delete(blockMap(blocks, op.id)[0], 1) |
| + return removeFromColumnsChildren(blocks, op.id) |
| + } |
| + case 'move_block': { |
| + const [, s] = slideMap(r, op.slide) |
| + const blocks = blocksOf(s) |
| + const [i, m] = blockMap(blocks, op.id) |
| + const copy = cloneMap(m) |
| + blocks.delete(i, 1) |
| + return blocks.insert(insertIndex(blocks, op.after ?? null), [copy]) |
| + } |
| + case 'set_block': return setBlock(r, op) |
| + case 'set_text': { |
| + const [, s] = slideMap(r, op.slide) |
| + const [, m] = blockMap(blocksOf(s), op.id) |
| + const kind = getString(m, 'type') ?? '' |
| + return writeText(fragmentOf(m, 'text'), textFor(kind, op.markdown)) |
| + } |
| + case 'set_phone': return setPhone(r, op) |
| + case 'register_asset': |
| + return r.assets.set(op.id, { ref: op.ref, name: op.name, width: op.width, height: op.height, alt: op.alt }) |
| + case 'remove_asset': |
| + return r.assets.delete(op.id) |
| + } |
| +} |
diff --git a/fixtures/ops-model.mjs b/fixtures/ops-model.mjs| new file mode 100644 |
| index 0000000..c1b805a |
| --- /dev/null |
| +++ b/fixtures/ops-model.mjs |
| @@ -0,0 +1,222 @@ |
| +// The checking pass of applyOps — core/src/ops.rs's Model and the |
| +// vocabularies it closes. |
| +// |
| +// A Yjs transaction cannot be rolled back, so "a failure applies |
| +// nothing" has to be true before the first write: every op, once parsed |
| +// (ops-parse.mjs), is checked against a model of the deck — the slide |
| +// ids, their block ids and types, the asset ids — that each op updates |
| +// as it is checked, so a reference that only becomes wrong halfway |
| +// through a batch (remove_block on a block an earlier op removed) is |
| +// caught here too. The messages are the core's, word for word, so the |
| +// inspector and the drive API say the same thing about the same mistake. |
| +import { getString, getArray, getObject, mapsOf } from './ydoc.mjs' |
| + |
| +export { parseOp } from './ops-parse.mjs' |
| + |
| +export const LAYOUTS = ['flow', 'canvas'] |
| +export const TEMPLATES = ['title', 'title-body', 'two-column', 'section', 'image-full', 'quote', 'blank'] |
| +export const REGIONS = ['heading', 'body', 'aside', 'media'] |
| +export const BACKGROUND_KINDS = ['theme', 'color', 'image'] |
| +export const SLIDE_PHONES = ['inherit', 'reflow'] |
| +export const TRANSITIONS = ['none', 'fade'] |
| +export const BLOCK_TYPES = ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer'] |
| +export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape'] |
| +export const SIZES = ['s', 'm', 'l'] |
| +export const MAX_TABLE_SIDE = 100 |
| + |
| +export const isTextType = kind => TEXT_TYPES.includes(kind) |
| + |
| +export class OpsError extends Error { |
| + constructor (message, index) { |
| + super(message) |
| + this.name = 'OpsError' |
| + this.index = index |
| + } |
| +} |
| + |
| +const fail = msg => { throw new Error(msg) } |
| +const isInt = v => Number.isInteger(v) |
| + |
| +function inVocab (what, value, vocab) { |
| + if (!vocab.includes(value)) fail(`${what} ${JSON.stringify(value)} is not one of ${vocab.join(', ')}`) |
| +} |
| +function nonEmpty (what, value) { |
| + if (value === '') fail(`${what} is empty`) |
| +} |
| + |
| +/** rows/cols from props when present: whole numbers within 1..=MAX_TABLE_SIDE. */ |
| +export function tableSide (props, key) { |
| + const v = props[key] |
| + if (v === undefined) return null |
| + if (!isInt(v) || v < 1 || v > MAX_TABLE_SIDE) fail(`table ${key} must be a whole number from 1 to ${MAX_TABLE_SIDE}`) |
| + return v |
| +} |
| + |
| +function blockIndex (s, id) { |
| + const i = s.blocks.findIndex(b => b.id === id) |
| + if (i === -1) fail(`no block ${JSON.stringify(id)} in slide ${JSON.stringify(s.id)}`) |
| + return i |
| +} |
| + |
| +/** A columns block's props.children: block ids, each naming a block on s other than selfId. null clears. */ |
| +function validateChildren (s, selfId, value) { |
| + if (value === null) return [] |
| + if (!Array.isArray(value)) fail('columns children must be an array of block ids') |
| + return value.map(cid => { |
| + if (typeof cid !== 'string') fail('columns children must be an array of block ids') |
| + if (cid === selfId) fail(`columns ${JSON.stringify(selfId)} cannot name itself in children`) |
| + if (!s.blocks.some(b => b.id === cid)) fail(`columns children names ${JSON.stringify(cid)}, not a block on slide ${JSON.stringify(s.id)}`) |
| + return cid |
| + }) |
| +} |
| + |
| +export class Model { |
| + static read (r) { |
| + const m = new Model() |
| + m.slides = mapsOf(r.slides).map(([, sm]) => { |
| + const blocks = getArray(sm, 'blocks') |
| + return { |
| + id: getString(sm, 'id') ?? '', |
| + blocks: blocks === null ? [] : mapsOf(blocks).map(([, b]) => { |
| + const kind = getString(b, 'type') ?? '' |
| + let children = null |
| + if (kind === 'columns') { |
| + const c = (getObject(b, 'props') ?? {}).children |
| + children = Array.isArray(c) ? c.filter(x => typeof x === 'string') : [] |
| + } |
| + return { id: getString(b, 'id') ?? '', kind, children } |
| + }) |
| + } |
| + }) |
| + m.assets = new Set() |
| + r.assets.forEach((_, k) => m.assets.add(k)) |
| + return m |
| + } |
| + |
| + slideIndex (id) { |
| + const i = this.slides.findIndex(s => s.id === id) |
| + if (i === -1) fail(`no slide ${JSON.stringify(id)}`) |
| + return i |
| + } |
| + |
| + hasSlide (id) { return this.slides.some(s => s.id === id) } |
| + |
| + slide (id) { return this.slides[this.slideIndex(id)] } |
| + |
| + check (op) { |
| + switch (op.op) { |
| + case 'set_meta': return |
| + case 'add_slide': { |
| + nonEmpty('id', op.id) |
| + if (this.hasSlide(op.id)) fail(`slide ${JSON.stringify(op.id)} already exists`) |
| + inVocab('layout', op.layout, LAYOUTS) |
| + if (op.template !== undefined) inVocab('template', op.template, TEMPLATES) |
| + const at = op.after === undefined ? this.slides.length : op.after === null ? 0 : this.slideIndex(op.after) + 1 |
| + this.slides.splice(at, 0, { id: op.id, blocks: [] }) |
| + return |
| + } |
| + case 'remove_slide': |
| + this.slides.splice(this.slideIndex(op.id), 1) |
| + return |
| + case 'move_slide': { |
| + this.slideIndex(op.id) |
| + if (op.after !== undefined) { |
| + if (op.after === op.id) fail('move_slide: after is the slide itself') |
| + this.slideIndex(op.after) |
| + } |
| + const [s] = this.slides.splice(this.slideIndex(op.id), 1) |
| + const at = op.after === undefined ? 0 : this.slideIndex(op.after) + 1 |
| + this.slides.splice(at, 0, s) |
| + return |
| + } |
| + case 'duplicate_slide': { |
| + nonEmpty('new_id', op.new_id) |
| + const i = this.slideIndex(op.id) |
| + if (this.hasSlide(op.new_id)) fail(`slide ${JSON.stringify(op.new_id)} already exists`) |
| + const n = this.slides[i].blocks.length |
| + if (op.new_block_ids.length !== n) fail(`duplicate_slide: ${n} blocks but ${op.new_block_ids.length} new_block_ids`) |
| + if (new Set(op.new_block_ids).size !== n || op.new_block_ids.some(x => x === '')) fail('duplicate_slide: new_block_ids must be distinct and non-empty') |
| + const idMap = new Map(this.slides[i].blocks.map((b, k) => [b.id, op.new_block_ids[k]])) |
| + const blocks = this.slides[i].blocks.map((b, k) => ({ |
| + id: op.new_block_ids[k], |
| + kind: b.kind, |
| + children: b.children === null ? null : b.children.map(c => idMap.get(c) ?? c) |
| + })) |
| + this.slides.splice(i + 1, 0, { id: op.new_id, blocks }) |
| + return |
| + } |
| + case 'set_slide': |
| + this.slideIndex(op.id) |
| + if (op.layout !== undefined) inVocab('layout', op.layout, LAYOUTS) |
| + if (op.template !== undefined) inVocab('template', op.template, TEMPLATES) |
| + if (op.background !== undefined) inVocab('background.kind', op.background.kind, BACKGROUND_KINDS) |
| + if (op.phone !== undefined) inVocab('phone', op.phone, SLIDE_PHONES) |
| + if (op.transition !== undefined) inVocab('transition', op.transition, TRANSITIONS) |
| + return |
| + case 'set_notes': |
| + this.slideIndex(op.id) |
| + return |
| + case 'add_block': { |
| + nonEmpty('id', op.id) |
| + inVocab('type', op.type, BLOCK_TYPES) |
| + if (op.region !== undefined) inVocab('region', op.region, REGIONS) |
| + if (op.type === 'table') { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') } |
| + const s = this.slide(op.slide) |
| + if (s.blocks.some(b => b.id === op.id)) fail(`block ${JSON.stringify(op.id)} already exists in slide ${JSON.stringify(op.slide)}`) |
| + let children = null |
| + if (op.type === 'columns') children = op.props.children === undefined ? [] : validateChildren(s, op.id, op.props.children) |
| + const at = op.after === undefined ? s.blocks.length : op.after === null ? 0 : blockIndex(s, op.after) + 1 |
| + s.blocks.splice(at, 0, { id: op.id, kind: op.type, children }) |
| + return |
| + } |
| + case 'remove_block': { |
| + const s = this.slide(op.slide) |
| + s.blocks.splice(blockIndex(s, op.id), 1) |
| + for (const b of s.blocks) if (b.children !== null) b.children = b.children.filter(c => c !== op.id) |
| + return |
| + } |
| + case 'move_block': { |
| + const s = this.slide(op.slide) |
| + const i = blockIndex(s, op.id) |
| + if (op.after !== undefined) { |
| + if (op.after === op.id) fail('move_block: after is the block itself') |
| + blockIndex(s, op.after) |
| + } |
| + const [b] = s.blocks.splice(i, 1) |
| + const at = op.after === undefined ? 0 : blockIndex(s, op.after) + 1 |
| + s.blocks.splice(at, 0, b) |
| + return |
| + } |
| + case 'set_block': { |
| + const s = this.slide(op.slide) |
| + const i = blockIndex(s, op.id) |
| + if (typeof op.region === 'string') inVocab('region', op.region, REGIONS) |
| + if (s.blocks[i].kind === 'table' && op.props !== undefined) { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') } |
| + if (s.blocks[i].kind === 'columns' && op.props !== undefined && op.props.children !== undefined) { |
| + s.blocks[i].children = validateChildren(s, op.id, op.props.children) |
| + } |
| + return |
| + } |
| + case 'set_text': { |
| + const s = this.slide(op.slide) |
| + const b = s.blocks[blockIndex(s, op.id)] |
| + if (!isTextType(b.kind)) fail(`block ${JSON.stringify(op.id)} is a ${b.kind} and carries no text`) |
| + return |
| + } |
| + case 'set_phone': { |
| + const s = this.slide(op.slide) |
| + blockIndex(s, op.id) |
| + if (op.phone.size !== undefined) inVocab('phone.size', op.phone.size, SIZES) |
| + return |
| + } |
| + case 'register_asset': |
| + nonEmpty('id', op.id) |
| + nonEmpty('ref', op.ref) |
| + if (this.assets.has(op.id)) fail(`asset ${JSON.stringify(op.id)} is already registered`) |
| + this.assets.add(op.id) |
| + return |
| + case 'remove_asset': |
| + if (!this.assets.delete(op.id)) fail(`no asset ${JSON.stringify(op.id)}`) |
| + } |
| + } |
| +} |
diff --git a/fixtures/ops-parse.mjs b/fixtures/ops-parse.mjs| new file mode 100644 |
| index 0000000..316202d |
| --- /dev/null |
| +++ b/fixtures/ops-parse.mjs |
| @@ -0,0 +1,114 @@ |
| +// The shape pass of applyOps — what serde does for core/src/ops.rs's Op |
| +// enum: required fields, the kinds of value each takes, and the difference |
| +// between an absent key and an explicit null where it matters (`after` |
| +// absent appends, `after: null` puts first; `accent: null` clears). The |
| +// result is one op in the enum's shape: absent fields undefined, nulls |
| +// kept only where they mean something. Every check here is one the core |
| +// makes too, so a batch this module accepts the core accepts. |
| + |
| +const fail = msg => { throw new Error(msg) } |
| + |
| +const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v) |
| +const isInt = v => Number.isInteger(v) |
| + |
| +function field (o, key, check, what) { |
| + const v = o[key] |
| + if (v === undefined) fail(`missing field \`${key}\``) |
| + if (!check(v)) fail(`invalid type for \`${key}\`, expected ${what}`) |
| + return v |
| +} |
| +function optional (o, key, check, what) { |
| + const v = o[key] |
| + if (v === undefined || v === null) return undefined |
| + if (!check(v)) fail(`invalid type for \`${key}\`, expected ${what}`) |
| + return v |
| +} |
| +/** undefined when absent, null when explicitly null (so `after: null` and `accent: null` mean something). */ |
| +function nullable (o, key, check, what) { |
| + const v = o[key] |
| + if (v === undefined) return undefined |
| + if (v === null) return null |
| + if (!check(v)) fail(`invalid type for \`${key}\`, expected ${what}`) |
| + return v |
| +} |
| +const str = (o, k) => field(o, k, v => typeof v === 'string', 'a string') |
| +const optStr = (o, k) => optional(o, k, v => typeof v === 'string', 'a string') |
| +const nullStr = (o, k) => nullable(o, k, v => typeof v === 'string', 'a string') |
| +const optNum = (o, k) => optional(o, k, v => typeof v === 'number' && Number.isFinite(v), 'a number') |
| +const optProps = (o, k) => optional(o, k, isObject, 'a map') |
| + |
| +function boxSpec (v) { |
| + if (!isObject(v)) fail('invalid type for `box`, expected a map') |
| + for (const k of ['x', 'y', 'w', 'h']) field(v, k, n => typeof n === 'number' && Number.isFinite(n), 'a number') |
| + return { x: v.x, y: v.y, w: v.w, h: v.h } |
| +} |
| + |
| +/** One op as the core's enum: absent fields undefined, explicit nulls kept where they mean something. */ |
| +export function parseOp (raw) { |
| + if (!isObject(raw)) fail('an op must be an object') |
| + const op = raw.op |
| + if (typeof op !== 'string') fail('missing field `op`') |
| + switch (op) { |
| + case 'set_meta': { |
| + let fonts = nullable(raw, 'fonts', isObject, 'a map') |
| + if (fonts) fonts = { heading: str(fonts, 'heading'), body: str(fonts, 'body') } |
| + return { op, title: optStr(raw, 'title'), theme: optStr(raw, 'theme'), accent: nullStr(raw, 'accent'), fonts } |
| + } |
| + case 'add_slide': |
| + return { op, id: str(raw, 'id'), after: nullStr(raw, 'after'), layout: str(raw, 'layout'), template: optStr(raw, 'template') } |
| + case 'remove_slide': |
| + case 'remove_asset': |
| + return { op, id: str(raw, 'id') } |
| + case 'move_slide': |
| + return { op, id: str(raw, 'id'), after: optStr(raw, 'after') } |
| + case 'duplicate_slide': { |
| + const ids = field(raw, 'new_block_ids', v => Array.isArray(v) && v.every(x => typeof x === 'string'), 'a list of strings') |
| + return { op, id: str(raw, 'id'), new_id: str(raw, 'new_id'), new_block_ids: ids } |
| + } |
| + case 'set_slide': { |
| + let background = optional(raw, 'background', isObject, 'a map') |
| + if (background) background = { kind: str(background, 'kind'), value: optStr(background, 'value') ?? '' } |
| + return { |
| + op, id: str(raw, 'id'), layout: optStr(raw, 'layout'), template: optStr(raw, 'template'), background, |
| + phone: optStr(raw, 'phone'), transition: optStr(raw, 'transition') |
| + } |
| + } |
| + case 'set_notes': |
| + return { op, id: str(raw, 'id'), markdown: str(raw, 'markdown') } |
| + case 'add_block': { |
| + const bx = raw.box === undefined || raw.box === null ? undefined : boxSpec(raw.box) |
| + return { |
| + op, slide: str(raw, 'slide'), id: str(raw, 'id'), type: str(raw, 'type'), props: optProps(raw, 'props') ?? {}, |
| + region: optStr(raw, 'region'), box: bx, z: optNum(raw, 'z'), after: nullStr(raw, 'after'), markdown: optStr(raw, 'markdown') |
| + } |
| + } |
| + case 'remove_block': |
| + return { op, slide: str(raw, 'slide'), id: str(raw, 'id') } |
| + case 'move_block': |
| + return { op, slide: str(raw, 'slide'), id: str(raw, 'id'), after: optStr(raw, 'after') } |
| + case 'set_block': { |
| + const bx = raw.box === undefined ? undefined : raw.box === null ? null : boxSpec(raw.box) |
| + return { op, slide: str(raw, 'slide'), id: str(raw, 'id'), props: optProps(raw, 'props'), region: nullStr(raw, 'region'), box: bx, z: optNum(raw, 'z') } |
| + } |
| + case 'set_text': |
| + return { op, slide: str(raw, 'slide'), id: str(raw, 'id'), markdown: str(raw, 'markdown') } |
| + case 'set_phone': { |
| + const p = field(raw, 'phone', isObject, 'a map') |
| + for (const k of Object.keys(p)) if (!['hidden', 'order', 'size', 'box'].includes(k)) fail(`unknown field \`${k}\` in phone`) |
| + const phone = { |
| + hidden: optional(p, 'hidden', v => typeof v === 'boolean', 'a boolean'), |
| + order: optional(p, 'order', isInt, 'an integer'), |
| + size: optStr(p, 'size'), |
| + box: p.box === undefined || p.box === null ? undefined : boxSpec(p.box) |
| + } |
| + return { op, slide: str(raw, 'slide'), id: str(raw, 'id'), phone } |
| + } |
| + case 'register_asset': |
| + return { |
| + op, id: str(raw, 'id'), ref: str(raw, 'ref'), name: optStr(raw, 'name') ?? '', |
| + width: optional(raw, 'width', isInt, 'an integer') ?? 0, height: optional(raw, 'height', isInt, 'an integer') ?? 0, alt: optStr(raw, 'alt') ?? '' |
| + } |
| + default: |
| + fail(`unknown variant \`${op}\``) |
| + } |
| +} |
diff --git a/fixtures/ops.mjs b/fixtures/ops.mjs| new file mode 100644 |
| index 0000000..a2b53f3 |
| --- /dev/null |
| +++ b/fixtures/ops.mjs |
| @@ -0,0 +1,48 @@ |
| +// The operations the inspector and the drive surface apply (spec §7.2), |
| +// over a Y.Doc in the browser — the same vocabulary as core/src/ops.rs, |
| +// with the same result. A new op is added to both or to neither; the |
| +// vectors under vectors/ hold the two to one view. |
| +// |
| +// applyOps(ydoc, ops, origin) parses and checks the whole batch against a |
| +// model of the deck first (ops-model.mjs) and throws an OpsError — with |
| +// the core's own message, prefixed `op N:` — having written nothing; |
| +// only then does it apply every op (ops-apply.mjs) inside one |
| +// ydoc.transact with the given origin, so the undo manager and the |
| +// save queue see one change. |
| +// |
| +// Ids: the core never draws entropy and neither does this module. Every |
| +// new slide or block id arrives in the op (`id`, `new_id`, |
| +// `new_block_ids`); the browser mints them with ids.mjs before calling. |
| +import { roots } from './ydoc.mjs' |
| +import { Model, OpsError, parseOp } from './ops-model.mjs' |
| +import { applyOne } from './ops-apply.mjs' |
| + |
| +export { OpsError } from './ops-model.mjs' |
| +export { view } from './view.mjs' |
| + |
| +function at (i, e, applying = false) { |
| + const msg = e instanceof Error ? e.message : String(e) |
| + return new OpsError(`op ${i}${applying ? ' (applying)' : ''}: ${msg}`, i) |
| +} |
| + |
| +/** |
| + * Applies ops (an array of §7.2 operations) to ydoc in one transaction. |
| + * Throws OpsError, applying nothing, when any op is malformed or names |
| + * something the deck (as the batch so far leaves it) does not have. |
| + */ |
| +export function applyOps (ydoc, ops, origin = 'ops') { |
| + if (!Array.isArray(ops)) throw new OpsError('ops must be a JSON array', -1) |
| + const parsed = ops.map((raw, i) => { |
| + try { return parseOp(raw) } catch (e) { throw at(i, e) } |
| + }) |
| + const r = roots(ydoc) |
| + const model = Model.read(r) |
| + parsed.forEach((op, i) => { |
| + try { model.check(op) } catch (e) { throw at(i, e) } |
| + }) |
| + ydoc.transact(() => { |
| + parsed.forEach((op, i) => { |
| + try { applyOne(r, op) } catch (e) { throw at(i, e, true) } |
| + }) |
| + }, origin) |
| +} |
diff --git a/fixtures/ops.test.mjs b/fixtures/ops.test.mjs| new file mode 100644 |
| index 0000000..492ff25 |
| --- /dev/null |
| +++ b/fixtures/ops.test.mjs |
| @@ -0,0 +1,319 @@ |
| +// The op vocabulary over a Y.Doc (ops.mjs), one test per op family, plus |
| +// the properties the core guarantees: a refused batch applies nothing, |
| +// columns.children is validated, a layout switch round-trips, and the |
| +// committed vectors are what generate.mjs would write today. |
| +import test from 'node:test' |
| +import assert from 'node:assert/strict' |
| +import { readFileSync, readdirSync } from 'node:fs' |
| +import { join, dirname } from 'node:path' |
| +import { fileURLToPath } from 'node:url' |
| +import * as Y from 'yjs' |
| +import { applyOps, view, OpsError } from './ops.mjs' |
| +import { buildVector, sortedJSON } from './generate.mjs' |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)) |
| + |
| +function fresh () { |
| + const doc = new Y.Doc({ guid: 'test' }) |
| + doc.clientID = 1 |
| + return doc |
| +} |
| + |
| +function withSlide (layout = 'flow') { |
| + const doc = fresh() |
| + applyOps(doc, [{ op: 'add_slide', id: 's1', layout, template: 'title-body' }]) |
| + return doc |
| +} |
| + |
| +const heading = (id, md, extra = {}) => ({ |
| + op: 'add_block', slide: 's1', id, type: 'heading', props: { level: 1 }, region: 'heading', markdown: md, ...extra |
| +}) |
| +const text = (id, md, extra = {}) => ({ |
| + op: 'add_block', slide: 's1', id, type: 'text', props: {}, region: 'body', markdown: md, ...extra |
| +}) |
| + |
| +test('empty_doc_views_as_defaults', () => { |
| + assert.deepEqual(view(fresh()), { |
| + meta: { title: '', theme: 'paper', accent: null, fonts: null, aspect: '16:9' }, |
| + slides: [], |
| + assets: {} |
| + }) |
| +}) |
| + |
| +test('set_meta', () => { |
| + const doc = fresh() |
| + applyOps(doc, [{ op: 'set_meta', title: 'T', theme: 'ink', accent: '#f00', fonts: { heading: 'A', body: 'B' } }]) |
| + assert.deepEqual(view(doc).meta, { title: 'T', theme: 'ink', accent: '#f00', fonts: { heading: 'A', body: 'B' }, aspect: '16:9' }) |
| + applyOps(doc, [{ op: 'set_meta', accent: null, fonts: null }]) |
| + assert.deepEqual(view(doc).meta, { title: 'T', theme: 'ink', accent: null, fonts: null, aspect: '16:9' }) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_meta', title: 5 }]), OpsError) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_meta', fonts: { heading: 'A' } }]), OpsError) |
| +}) |
| + |
| +test('slides: add, move, remove, set_slide, set_notes', () => { |
| + const doc = fresh() |
| + applyOps(doc, [ |
| + { op: 'add_slide', id: 's1', layout: 'flow', template: 'title' }, |
| + { op: 'add_slide', id: 's2', layout: 'flow' }, |
| + { op: 'add_slide', id: 's0', layout: 'canvas', after: null }, |
| + { op: 'add_slide', id: 's1b', layout: 'flow', after: 's1' } |
| + ]) |
| + let v = view(doc) |
| + assert.deepEqual(v.slides.map(s => s.id), ['s0', 's1', 's1b', 's2']) |
| + assert.deepEqual(v.slides[1], { |
| + id: 's1', layout: 'flow', template: 'title', background: { kind: 'theme', value: '' }, |
| + phone: 'inherit', transition: 'none', notes: [], blocks: [] |
| + }) |
| + assert.equal(v.slides[3].template, 'blank') |
| + |
| + applyOps(doc, [{ op: 'move_slide', id: 's2', after: null }, { op: 'move_slide', id: 's0', after: 's1b' }]) |
| + assert.deepEqual(view(doc).slides.map(s => s.id), ['s2', 's1', 's1b', 's0']) |
| + |
| + applyOps(doc, [ |
| + { op: 'remove_slide', id: 's1b' }, |
| + { op: 'set_slide', id: 's1', template: 'quote', background: { kind: 'color', value: '#123' }, phone: 'reflow', transition: 'fade' }, |
| + { op: 'set_notes', id: 's1', markdown: 'Say **hello**\n\n- one\n- two' } |
| + ]) |
| + v = view(doc) |
| + assert.deepEqual(v.slides.map(s => s.id), ['s2', 's1', 's0']) |
| + const s1 = v.slides[1] |
| + assert.equal(s1.template, 'quote') |
| + assert.deepEqual(s1.background, { kind: 'color', value: '#123' }) |
| + assert.equal(s1.phone, 'reflow') |
| + assert.equal(s1.transition, 'fade') |
| + assert.deepEqual(s1.notes, [[{ t: 'Say ' }, { t: 'hello', b: true }], [{ t: 'one' }], [{ t: 'two' }]]) |
| + |
| + assert.throws(() => applyOps(doc, [{ op: 'add_slide', id: 's1', layout: 'flow' }]), /already exists/) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_slide', id: 'x', layout: 'grid' }]), /layout "grid" is not one of/) |
| + assert.throws(() => applyOps(doc, [{ op: 'move_slide', id: 's1', after: 's1' }]), /after is the slide itself/) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_slide', id: 'nope' }]), /no slide "nope"/) |
| +}) |
| + |
| +test('duplicate_slide', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [ |
| + heading('h1', 'Hi'), |
| + text('t1', 'a'), |
| + text('t2', 'b'), |
| + { op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { count: 2, children: ['t1', 't2'] } }, |
| + { op: 'duplicate_slide', id: 's1', new_id: 's2', new_block_ids: ['h2', 't3', 't4', 'c2'] } |
| + ]) |
| + const v = view(doc) |
| + assert.deepEqual(v.slides.map(s => s.id), ['s1', 's2']) |
| + const copy = v.slides[1] |
| + assert.deepEqual(copy.blocks.map(b => b.id), ['h2', 't3', 't4', 'c2']) |
| + assert.deepEqual(copy.blocks[3].props, { count: 2, children: ['t3', 't4'] }) |
| + assert.deepEqual(copy.blocks[0].text, v.slides[0].blocks[0].text) |
| + assert.throws(() => applyOps(doc, [{ op: 'duplicate_slide', id: 's1', new_id: 's3', new_block_ids: ['a'] }]), /4 blocks but 1 new_block_ids/) |
| + assert.throws(() => applyOps(doc, [{ op: 'duplicate_slide', id: 's1', new_id: 's3', new_block_ids: ['a', 'a', 'b', 'c'] }]), /distinct and non-empty/) |
| +}) |
| + |
| +test('blocks: add, move, remove, set_block', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [ |
| + heading('h1', 'Hello **world**'), |
| + text('t1', 'one'), |
| + text('t0', 'zero', { after: null }), |
| + text('t1b', 'x', { after: 't1' }), |
| + { op: 'add_block', slide: 's1', id: 'img', type: 'image', props: { asset: 'a1', alt: '' } }, |
| + { op: 'add_block', slide: 's1', id: 'div', type: 'divider' } |
| + ]) |
| + let v = view(doc) |
| + assert.deepEqual(v.slides[0].blocks.map(b => b.id), ['t0', 'h1', 't1', 't1b', 'img', 'div']) |
| + assert.deepEqual(v.slides[0].blocks[1], { |
| + id: 'h1', type: 'heading', props: { level: 1 }, region: 'heading', box: null, z: 0, phone: {}, |
| + text: [[{ t: 'Hello ' }, { t: 'world', b: true }]] |
| + }) |
| + assert.equal(v.slides[0].blocks[4].region, 'media') |
| + assert.equal('text' in v.slides[0].blocks[4], false) |
| + assert.equal(v.slides[0].blocks[5].region, 'body') |
| + |
| + applyOps(doc, [ |
| + { op: 'move_block', slide: 's1', id: 'div', after: null }, |
| + { op: 'move_block', slide: 's1', id: 't0', after: 'img' }, |
| + { op: 'remove_block', slide: 's1', id: 't1b' }, |
| + { op: 'set_block', slide: 's1', id: 'h1', props: { level: 2, extra: 'y' }, region: 'body' }, |
| + { op: 'set_block', slide: 's1', id: 'h1', props: { extra: null } }, |
| + { op: 'set_text', slide: 's1', id: 'h1', markdown: '*new*' } |
| + ]) |
| + v = view(doc) |
| + assert.deepEqual(v.slides[0].blocks.map(b => b.id), ['div', 'h1', 't1', 'img', 't0']) |
| + assert.deepEqual(v.slides[0].blocks[1].props, { level: 2 }) |
| + assert.equal(v.slides[0].blocks[1].region, 'body') |
| + assert.deepEqual(v.slides[0].blocks[1].text, [[{ t: 'new', i: true }]]) |
| + |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'h1', type: 'text' }]), /already exists/) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'n', type: 'video' }]), /type "video" is not one of/) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_text', slide: 's1', id: 'img', markdown: 'x' }]), /is a image and carries no text/) |
| + assert.throws(() => applyOps(doc, [{ op: 'move_block', slide: 's1', id: 'h1', after: 'h1' }]), /after is the block itself/) |
| + assert.throws(() => applyOps(doc, [{ op: 'remove_block', slide: 's1', id: 'zz' }]), /no block "zz" in slide "s1"/) |
| +}) |
| + |
| +test('set_phone', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [text('t1', 'x'), { op: 'set_phone', slide: 's1', id: 't1', phone: { hidden: true, order: 2, size: 's', box: { x: 0, y: 0.5, w: 1, h: 0.25 } } }]) |
| + assert.deepEqual(view(doc).slides[0].blocks[0].phone, { hidden: true, order: 2, size: 's', box: { x: 0, y: 0.5, w: 1, h: 0.25 } }) |
| + applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { size: 'l' } }]) |
| + assert.deepEqual(view(doc).slides[0].blocks[0].phone, { size: 'l' }) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { size: 'xl' } }]), /phone.size "xl" is not one of/) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { colour: 'red' } }]), OpsError) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_phone', slide: 's1', id: 't1', phone: { order: 1.5 } }]), OpsError) |
| +}) |
| + |
| +test('table cells follow rows and cols', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'tb', type: 'table', props: { header: true } }]) |
| + let b = view(doc).slides[0].blocks[0] |
| + assert.deepEqual(b.props, { header: true, rows: 2, cols: 2, cells: [[], [], [], []] }) |
| + assert.equal('text' in b, false) |
| + applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 3 } }]) |
| + b = view(doc).slides[0].blocks[0] |
| + assert.equal(b.props.cells.length, 6) |
| + applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 1, cols: 1 } }]) |
| + assert.equal(view(doc).slides[0].blocks[0].props.cells.length, 1) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'tb', props: { rows: 0 } }]), /table rows must be a whole number from 1 to 100/) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 't2', type: 'table', props: { cols: 101 } }]), /table cols/) |
| +}) |
| + |
| +test('canvas blocks: box defaults from the region, z from the index', () => { |
| + const doc = withSlide('canvas') |
| + applyOps(doc, [ |
| + heading('h1', 'x'), |
| + { op: 'add_block', slide: 's1', id: 't1', type: 'text', box: { x: 0.1, y: 0.2, w: 0.3, h: 0.44444 }, z: 7 }, |
| + { op: 'set_block', slide: 's1', id: 'h1', box: { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, z: 3 } |
| + ]) |
| + const v = view(doc) |
| + assert.deepEqual(v.slides[0].blocks[0], { |
| + id: 'h1', type: 'heading', props: { level: 1 }, region: null, box: { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, z: 3, phone: {}, text: [[{ t: 'x' }]] |
| + }) |
| + assert.deepEqual(v.slides[0].blocks[1].box, { x: 0.1, y: 0.2, w: 0.3, h: 0.4444 }) |
| + assert.equal(v.slides[0].blocks[1].z, 7) |
| + applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'h1', box: null }]) |
| + assert.equal(view(doc).slides[0].blocks[0].box, null) |
| +}) |
| + |
| +test('assets: register and remove', () => { |
| + const doc = fresh() |
| + applyOps(doc, [{ op: 'register_asset', id: 'a1', ref: 'blob/1', name: 'pic.png', width: 10, height: 20, alt: 'A' }, { op: 'register_asset', id: 'a2', ref: 'blob/2' }]) |
| + assert.deepEqual(view(doc).assets, { |
| + a1: { ref: 'blob/1', name: 'pic.png', width: 10, height: 20, alt: 'A' }, |
| + a2: { ref: 'blob/2', name: '', width: 0, height: 0, alt: '' } |
| + }) |
| + applyOps(doc, [{ op: 'remove_asset', id: 'a1' }]) |
| + assert.deepEqual(Object.keys(view(doc).assets), ['a2']) |
| + assert.throws(() => applyOps(doc, [{ op: 'register_asset', id: 'a2', ref: 'x' }]), /already registered/) |
| + assert.throws(() => applyOps(doc, [{ op: 'remove_asset', id: 'a1' }]), /no asset "a1"/) |
| + assert.throws(() => applyOps(doc, [{ op: 'register_asset', id: 'a3', ref: '' }]), /ref is empty/) |
| +}) |
| + |
| +test('markdown: every mark, code blocks are plain lines', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [ |
| + text('t1', '**b** *i* `c *x*` ==h== [t](u) \\* lone* **open'), |
| + text('t2', '**a *b* c** [**x**](y)'), |
| + { op: 'add_block', slide: 's1', id: 'c1', type: 'code', props: { language: 'go' }, markdown: 'x\n\n**y**' } |
| + ]) |
| + const [t1, t2, c1] = view(doc).slides[0].blocks |
| + assert.deepEqual(t1.text, [[ |
| + { t: 'b', b: true }, { t: ' ' }, { t: 'i', i: true }, { t: ' ' }, { t: 'c *x*', c: true }, { t: ' ' }, |
| + { t: 'h', h: true }, { t: ' ' }, { t: 't', href: 'u' }, { t: ' * lone* **open' } |
| + ]]) |
| + assert.deepEqual(t2.text, [[{ t: 'a ', b: true }, { t: 'b', b: true, i: true }, { t: ' c', b: true }, { t: ' ' }, { t: 'x', b: true, href: 'y' }]]) |
| + assert.deepEqual(c1.text, [[{ t: 'x' }], [], [{ t: '**y**' }]]) |
| +}) |
| + |
| +test('batch_is_atomic', () => { |
| + const doc = withSlide() |
| + const before = Y.encodeStateAsUpdate(doc) |
| + let origins = 0 |
| + doc.on('afterTransaction', () => { origins++ }) |
| + let err = null |
| + try { |
| + applyOps(doc, [ |
| + heading('h1', 'x'), |
| + { op: 'add_slide', id: 's2', layout: 'flow' }, |
| + { op: 'remove_block', slide: 's1', id: 'missing' } |
| + ]) |
| + } catch (e) { err = e } |
| + assert.ok(err instanceof OpsError) |
| + assert.equal(err.index, 2) |
| + assert.match(err.message, /^op 2: no block "missing" in slide "s1"/) |
| + assert.deepEqual(Y.encodeStateAsUpdate(doc), before) |
| + assert.equal(origins, 0) |
| + assert.throws(() => applyOps(doc, [{ op: 'sing' }]), /op 0:/) |
| + assert.throws(() => applyOps(doc, 'nope'), /ops must be a JSON array/) |
| +}) |
| + |
| +test('a batch applies in one transaction with the given origin', () => { |
| + const doc = withSlide() |
| + const seen = [] |
| + doc.on('afterTransaction', tr => { seen.push(tr.origin) }) |
| + applyOps(doc, [heading('h1', 'x'), text('t1', 'y')], 'inspector') |
| + assert.deepEqual(seen, ['inspector']) |
| +}) |
| + |
| +test('columns_children_validated', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [text('t1', 'a'), text('t2', 'b')]) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: ['t1', 'zz'] } }]), /names "zz", not a block on slide "s1"/) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: ['c1'] } }]), /cannot name itself/) |
| + assert.throws(() => applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { children: 't1' } }]), /must be an array of block ids/) |
| + applyOps(doc, [{ op: 'add_block', slide: 's1', id: 'c1', type: 'columns', props: { count: 2, children: ['t1', 't2'] } }]) |
| + assert.throws(() => applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c1', props: { children: ['t1', 't9'] } }]), /names "t9"/) |
| + // Removing a child prunes it from the columns block. |
| + applyOps(doc, [{ op: 'remove_block', slide: 's1', id: 't1' }]) |
| + assert.deepEqual(view(doc).slides[0].blocks.find(b => b.id === 'c1').props.children, ['t2']) |
| + // The model follows the batch: a block removed earlier in the same batch is gone. |
| + assert.throws(() => applyOps(doc, [ |
| + { op: 'remove_block', slide: 's1', id: 't2' }, |
| + { op: 'set_block', slide: 's1', id: 'c1', props: { children: ['t2'] } } |
| + ]), /op 1: columns children names "t2"/) |
| + applyOps(doc, [{ op: 'set_block', slide: 's1', id: 'c1', props: { children: null } }]) |
| + assert.deepEqual(view(doc).slides[0].blocks.find(b => b.id === 'c1').props, { count: 2 }) |
| +}) |
| + |
| +test('layout_switch_round_trip', () => { |
| + const doc = withSlide() |
| + applyOps(doc, [heading('h1', 'x'), text('t1', 'a'), text('t2', 'b'), { op: 'add_block', slide: 's1', id: 'as', type: 'text', region: 'aside' }]) |
| + applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'canvas' }]) |
| + let blocks = view(doc).slides[0].blocks |
| + assert.deepEqual(blocks.map(b => [b.id, b.region, b.box, b.z]), [ |
| + ['h1', null, { x: 0.06, y: 0.08, w: 0.88, h: 0.18 }, 0], |
| + ['t1', null, { x: 0.06, y: 0.3, w: 0.44, h: 0.31 }, 1], |
| + ['t2', null, { x: 0.06, y: 0.61, w: 0.44, h: 0.31 }, 2], |
| + ['as', null, { x: 0.54, y: 0.3, w: 0.4, h: 0.62 }, 3] |
| + ]) |
| + // Back to flow: regions from position, order top to bottom then left to right. |
| + applyOps(doc, [{ op: 'set_block', slide: 's1', id: 't2', box: { x: 0.5, y: 0.05, w: 0.2, h: 0.2 } }]) |
| + applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'flow' }]) |
| + blocks = view(doc).slides[0].blocks |
| + assert.deepEqual(blocks.map(b => [b.id, b.region, b.box, b.z]), [ |
| + ['t2', 'body', null, 0], |
| + ['h1', 'heading', null, 0], |
| + ['t1', 'body', null, 0], |
| + ['as', 'body', null, 0] |
| + ]) |
| + // Setting the same layout again is a no-op. |
| + const before = view(doc) |
| + applyOps(doc, [{ op: 'set_slide', id: 's1', layout: 'flow' }]) |
| + assert.deepEqual(view(doc), before) |
| +}) |
| + |
| +test('view_matches_snapshot_fixtures', () => { |
| + const dataDir = join(here, 'data') |
| + const names = readdirSync(dataDir).filter(f => f.endsWith('.json')).map(f => f.slice(0, -5)) |
| + assert.ok(names.length >= 5, 'five vectors at least') |
| + const ops = new Set() |
| + for (const name of names) { |
| + const data = JSON.parse(readFileSync(join(dataDir, `${name}.json`), 'utf8')) |
| + data.ops.forEach(o => ops.add(o.op)) |
| + const { update, view: v } = buildVector(name, data) |
| + const wantView = readFileSync(join(here, 'vectors', `${name}.view.json`), 'utf8') |
| + assert.equal(sortedJSON(v), wantView, `${name}.view.json is stale: run node generate.mjs`) |
| + const wantUpdate = readFileSync(join(here, 'vectors', `${name}.yjs`)) |
| + assert.ok(Buffer.from(update).equals(wantUpdate), `${name}.yjs is stale: run node generate.mjs`) |
| + } |
| + const every = ['set_meta', 'add_slide', 'remove_slide', 'move_slide', 'duplicate_slide', 'set_slide', 'set_notes', |
| + 'add_block', 'remove_block', 'move_block', 'set_block', 'set_text', 'set_phone', 'register_asset', 'remove_asset'] |
| + assert.deepEqual(every.filter(o => !ops.has(o)), [], 'every op appears in some vector') |
| +}) |
diff --git a/fixtures/package-lock.json b/fixtures/package-lock.json| new file mode 100644 |
| index 0000000..fee721c |
| --- /dev/null |
| +++ b/fixtures/package-lock.json |
| @@ -0,0 +1,715 @@ |
| +{ |
| + "name": "slides-fixtures", |
| + "lockfileVersion": 3, |
| + "requires": true, |
| + "packages": { |
| + "": { |
| + "name": "slides-fixtures", |
| + "dependencies": { |
| + "lib0": "0.2.117", |
| + "prosemirror-commands": "1.7.2", |
| + "prosemirror-history": "1.5.0", |
| + "prosemirror-inputrules": "1.5.1", |
| + "prosemirror-keymap": "1.2.3", |
| + "prosemirror-model": "1.25.11", |
| + "prosemirror-schema-list": "1.5.1", |
| + "prosemirror-state": "1.4.4", |
| + "prosemirror-view": "1.42.3", |
| + "y-prosemirror": "1.3.7", |
| + "y-protocols": "1.0.7", |
| + "yjs": "13.6.27" |
| + }, |
| + "devDependencies": { |
| + "esbuild": "0.28.2" |
| + } |
| + }, |
| + "node_modules/@esbuild/aix-ppc64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", |
| + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", |
| + "cpu": [ |
| + "ppc64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "aix" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/android-arm": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", |
| + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", |
| + "cpu": [ |
| + "arm" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "android" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/android-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", |
| + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "android" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/android-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", |
| + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "android" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/darwin-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", |
| + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "darwin" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/darwin-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", |
| + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "darwin" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/freebsd-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", |
| + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "freebsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/freebsd-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", |
| + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "freebsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-arm": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", |
| + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", |
| + "cpu": [ |
| + "arm" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", |
| + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-ia32": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", |
| + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", |
| + "cpu": [ |
| + "ia32" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-loong64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", |
| + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", |
| + "cpu": [ |
| + "loong64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-mips64el": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", |
| + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", |
| + "cpu": [ |
| + "mips64el" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-ppc64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", |
| + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", |
| + "cpu": [ |
| + "ppc64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-riscv64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", |
| + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", |
| + "cpu": [ |
| + "riscv64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-s390x": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", |
| + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", |
| + "cpu": [ |
| + "s390x" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/linux-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", |
| + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "linux" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/netbsd-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", |
| + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "netbsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/netbsd-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", |
| + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "netbsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/openbsd-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", |
| + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "openbsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/openbsd-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", |
| + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "openbsd" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/openharmony-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", |
| + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "openharmony" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/sunos-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", |
| + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "sunos" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/win32-arm64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", |
| + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", |
| + "cpu": [ |
| + "arm64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "win32" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/win32-ia32": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", |
| + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", |
| + "cpu": [ |
| + "ia32" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "win32" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/@esbuild/win32-x64": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", |
| + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", |
| + "cpu": [ |
| + "x64" |
| + ], |
| + "dev": true, |
| + "license": "MIT", |
| + "optional": true, |
| + "os": [ |
| + "win32" |
| + ], |
| + "engines": { |
| + "node": ">=18" |
| + } |
| + }, |
| + "node_modules/esbuild": { |
| + "version": "0.28.2", |
| + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", |
| + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", |
| + "dev": true, |
| + "hasInstallScript": true, |
| + "license": "MIT", |
| + "bin": { |
| + "esbuild": "bin/esbuild" |
| + }, |
| + "engines": { |
| + "node": ">=18" |
| + }, |
| + "optionalDependencies": { |
| + "@esbuild/aix-ppc64": "0.28.2", |
| + "@esbuild/android-arm": "0.28.2", |
| + "@esbuild/android-arm64": "0.28.2", |
| + "@esbuild/android-x64": "0.28.2", |
| + "@esbuild/darwin-arm64": "0.28.2", |
| + "@esbuild/darwin-x64": "0.28.2", |
| + "@esbuild/freebsd-arm64": "0.28.2", |
| + "@esbuild/freebsd-x64": "0.28.2", |
| + "@esbuild/linux-arm": "0.28.2", |
| + "@esbuild/linux-arm64": "0.28.2", |
| + "@esbuild/linux-ia32": "0.28.2", |
| + "@esbuild/linux-loong64": "0.28.2", |
| + "@esbuild/linux-mips64el": "0.28.2", |
| + "@esbuild/linux-ppc64": "0.28.2", |
| + "@esbuild/linux-riscv64": "0.28.2", |
| + "@esbuild/linux-s390x": "0.28.2", |
| + "@esbuild/linux-x64": "0.28.2", |
| + "@esbuild/netbsd-arm64": "0.28.2", |
| + "@esbuild/netbsd-x64": "0.28.2", |
| + "@esbuild/openbsd-arm64": "0.28.2", |
| + "@esbuild/openbsd-x64": "0.28.2", |
| + "@esbuild/openharmony-arm64": "0.28.2", |
| + "@esbuild/sunos-x64": "0.28.2", |
| + "@esbuild/win32-arm64": "0.28.2", |
| + "@esbuild/win32-ia32": "0.28.2", |
| + "@esbuild/win32-x64": "0.28.2" |
| + } |
| + }, |
| + "node_modules/isomorphic.js": { |
| + "version": "0.2.5", |
| + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", |
| + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", |
| + "license": "MIT", |
| + "funding": { |
| + "type": "GitHub Sponsors ❤", |
| + "url": "https://github.com/sponsors/dmonad" |
| + } |
| + }, |
| + "node_modules/lib0": { |
| + "version": "0.2.117", |
| + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", |
| + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "isomorphic.js": "^0.2.4" |
| + }, |
| + "bin": { |
| + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", |
| + "0gentesthtml": "bin/gentesthtml.js", |
| + "0serve": "bin/0serve.js" |
| + }, |
| + "engines": { |
| + "node": ">=16" |
| + }, |
| + "funding": { |
| + "type": "GitHub Sponsors ❤", |
| + "url": "https://github.com/sponsors/dmonad" |
| + } |
| + }, |
| + "node_modules/orderedmap": { |
| + "version": "2.1.1", |
| + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", |
| + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", |
| + "license": "MIT" |
| + }, |
| + "node_modules/prosemirror-commands": { |
| + "version": "1.7.2", |
| + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", |
| + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-model": "^1.0.0", |
| + "prosemirror-state": "^1.0.0", |
| + "prosemirror-transform": "^1.10.2" |
| + } |
| + }, |
| + "node_modules/prosemirror-history": { |
| + "version": "1.5.0", |
| + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", |
| + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-state": "^1.2.2", |
| + "prosemirror-transform": "^1.0.0", |
| + "prosemirror-view": "^1.31.0", |
| + "rope-sequence": "^1.3.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-inputrules": { |
| + "version": "1.5.1", |
| + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", |
| + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-state": "^1.0.0", |
| + "prosemirror-transform": "^1.0.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-keymap": { |
| + "version": "1.2.3", |
| + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", |
| + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-state": "^1.0.0", |
| + "w3c-keyname": "^2.2.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-model": { |
| + "version": "1.25.11", |
| + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", |
| + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "orderedmap": "^2.0.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-schema-list": { |
| + "version": "1.5.1", |
| + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", |
| + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-model": "^1.0.0", |
| + "prosemirror-state": "^1.0.0", |
| + "prosemirror-transform": "^1.7.3" |
| + } |
| + }, |
| + "node_modules/prosemirror-state": { |
| + "version": "1.4.4", |
| + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", |
| + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-model": "^1.0.0", |
| + "prosemirror-transform": "^1.0.0", |
| + "prosemirror-view": "^1.27.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-transform": { |
| + "version": "1.12.1", |
| + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", |
| + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-model": "^1.21.0" |
| + } |
| + }, |
| + "node_modules/prosemirror-view": { |
| + "version": "1.42.3", |
| + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", |
| + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "prosemirror-model": "^1.25.8", |
| + "prosemirror-state": "^1.0.0", |
| + "prosemirror-transform": "^1.1.0" |
| + } |
| + }, |
| + "node_modules/rope-sequence": { |
| + "version": "1.3.4", |
| + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", |
| + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", |
| + "license": "MIT" |
| + }, |
| + "node_modules/w3c-keyname": { |
| + "version": "2.2.8", |
| + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", |
| + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", |
| + "license": "MIT" |
| + }, |
| + "node_modules/y-prosemirror": { |
| + "version": "1.3.7", |
| + "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", |
| + "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "lib0": "^0.2.109" |
| + }, |
| + "engines": { |
| + "node": ">=16.0.0", |
| + "npm": ">=8.0.0" |
| + }, |
| + "funding": { |
| + "type": "GitHub Sponsors ❤", |
| + "url": "https://github.com/sponsors/dmonad" |
| + }, |
| + "peerDependencies": { |
| + "prosemirror-model": "^1.7.1", |
| + "prosemirror-state": "^1.2.3", |
| + "prosemirror-view": "^1.9.10", |
| + "y-protocols": "^1.0.1", |
| + "yjs": "^13.5.38" |
| + } |
| + }, |
| + "node_modules/y-protocols": { |
| + "version": "1.0.7", |
| + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", |
| + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "lib0": "^0.2.85" |
| + }, |
| + "engines": { |
| + "node": ">=16.0.0", |
| + "npm": ">=8.0.0" |
| + }, |
| + "funding": { |
| + "type": "GitHub Sponsors ❤", |
| + "url": "https://github.com/sponsors/dmonad" |
| + }, |
| + "peerDependencies": { |
| + "yjs": "^13.0.0" |
| + } |
| + }, |
| + "node_modules/yjs": { |
| + "version": "13.6.27", |
| + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", |
| + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", |
| + "license": "MIT", |
| + "dependencies": { |
| + "lib0": "^0.2.99" |
| + }, |
| + "engines": { |
| + "node": ">=16.0.0", |
| + "npm": ">=8.0.0" |
| + }, |
| + "funding": { |
| + "type": "GitHub Sponsors ❤", |
| + "url": "https://github.com/sponsors/dmonad" |
| + } |
| + } |
| + } |
| +} |
diff --git a/fixtures/package.json b/fixtures/package.json| new file mode 100644 |
| index 0000000..2c5defa |
| --- /dev/null |
| +++ b/fixtures/package.json |
| @@ -0,0 +1,27 @@ |
| +{ |
| + "name": "slides-fixtures", |
| + "private": true, |
| + "type": "module", |
| + "description": "The JS side of the Slides deck contract: the op vocabulary over a Y.Doc (ops.mjs), the constrained ProseMirror schema (schema.mjs), and the generator of the vectors the Rust core must reproduce (generate.mjs writes vectors/, internal/slides/core/vectors_test.go checks them).", |
| + "scripts": { |
| + "test": "node --test", |
| + "generate": "node generate.mjs" |
| + }, |
| + "dependencies": { |
| + "lib0": "0.2.117", |
| + "prosemirror-commands": "1.7.2", |
| + "prosemirror-history": "1.5.0", |
| + "prosemirror-inputrules": "1.5.1", |
| + "prosemirror-keymap": "1.2.3", |
| + "prosemirror-model": "1.25.11", |
| + "prosemirror-schema-list": "1.5.1", |
| + "prosemirror-state": "1.4.4", |
| + "prosemirror-view": "1.42.3", |
| + "y-prosemirror": "1.3.7", |
| + "y-protocols": "1.0.7", |
| + "yjs": "13.6.27" |
| + }, |
| + "devDependencies": { |
| + "esbuild": "0.28.2" |
| + } |
| +} |
diff --git a/fixtures/schema.mjs b/fixtures/schema.mjs| new file mode 100644 |
| index 0000000..809061c |
| --- /dev/null |
| +++ b/fixtures/schema.mjs |
| @@ -0,0 +1,147 @@ |
| +// The text schema (spec §2.6) on both sides of the bridge: the ProseMirror |
| +// 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. |
| +// |
| +// The ProseMirror marks are NAMED after those attributes on purpose. |
| +// y-prosemirror 1.3.7 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. |
| +import * as Y from 'yjs' |
| +import { Schema } from 'prosemirror-model' |
| +import { parse, run, pushRun } from './markdown.mjs' |
| + |
| +export const schema = new Schema({ |
| + nodes: { |
| + doc: { content: 'paragraph+' }, |
| + paragraph: { |
| + content: 'text*', |
| + parseDOM: [{ tag: 'p' }], |
| + toDOM: () => ['p', 0] |
| + }, |
| + text: {} |
| + }, |
| + marks: { |
| + b: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] }, |
| + i: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] }, |
| + c: { parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0] }, |
| + h: { parseDOM: [{ tag: 'mark' }], toDOM: () => ['mark', 0] }, |
| + href: { |
| + attrs: { href: { default: null } }, |
| + inclusive: false, |
| + parseDOM: [{ tag: 'a[href]', getAttrs: dom => ({ href: dom.getAttribute('href') }) }], |
| + toDOM: mark => ['a', { href: mark.attrs.href }, 0] |
| + } |
| + } |
| +}) |
| + |
| +/** The Markdown subset as paragraphs of runs (the core's markdown::parse). */ |
| +export const paragraphsFromMarkdown = parse |
| + |
| +const MARK_KEYS = ['b', 'i', 'c', 'h'] |
| + |
| +/** The XmlText formatting attributes for a run, in the core's shape. */ |
| +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 |
| + return attrs |
| +} |
| + |
| +/** One <paragraph> element holding one XmlText with the paragraph's runs. */ |
| +export function paragraphElement (paragraph) { |
| + const el = new Y.XmlElement('paragraph') |
| + const text = new Y.XmlText() |
| + el.insert(0, [text]) |
| + const delta = [] |
| + for (const r of paragraph) { |
| + if (r.t === '') continue |
| + const attrs = runAttributes(r) |
| + delta.push(Object.keys(attrs).length ? { insert: r.t, attributes: attrs } : { insert: r.t }) |
| + } |
| + if (delta.length) text.applyDelta(delta) |
| + return el |
| +} |
| + |
| +/** A new fragment holding paragraphs, ready to insert (the core's text_prelim). */ |
| +export function textFragment (paragraphs) { |
| + const f = new Y.XmlFragment() |
| + f.insert(0, paragraphs.map(paragraphElement)) |
| + return f |
| +} |
| + |
| +/** Replaces the whole content of an integrated XmlFragment with paragraphs. */ |
| +export function writeText (fragment, paragraphs) { |
| + if (fragment.length > 0) fragment.delete(0, fragment.length) |
| + fragment.insert(0, paragraphs.map(paragraphElement)) |
| +} |
| + |
| +/** The core's `truthy`: anything but null, undefined and false is on. */ |
| +function truthy (v) { |
| + return v !== null && v !== undefined && v !== false |
| +} |
| + |
| +function pushRuns (text, out) { |
| + for (const chunk of text.toDelta()) { |
| + if (typeof chunk.insert !== 'string' || chunk.insert === '') continue |
| + const a = chunk.attributes || {} |
| + pushRun(out, run(chunk.insert, { |
| + b: truthy(a.b), |
| + i: truthy(a.i), |
| + c: truthy(a.c), |
| + h: truthy(a.h), |
| + href: typeof a.href === 'string' ? a.href : null |
| + })) |
| + } |
| +} |
| + |
| +/** |
| + * The paragraphs of a text fragment, as tolerant as the core's reader: |
| + * several texts in one paragraph concatenate, a bare text at fragment |
| + * level is its own paragraph, an element inside a paragraph contributes |
| + * its string. |
| + */ |
| +export function readText (fragment) { |
| + const paragraphs = [] |
| + for (const child of fragment.toArray()) { |
| + if (child instanceof Y.XmlText) { |
| + const p = [] |
| + pushRuns(child, p) |
| + paragraphs.push(p) |
| + } else if (child instanceof Y.XmlElement) { |
| + const p = [] |
| + for (const inner of child.toArray()) { |
| + if (inner instanceof Y.XmlText) pushRuns(inner, p) |
| + else { |
| + const s = inner.toString() |
| + if (s !== '') p.push(run(s)) |
| + } |
| + } |
| + paragraphs.push(p) |
| + } else if (child instanceof Y.XmlFragment) { |
| + paragraphs.push(...readText(child)) |
| + } |
| + } |
| + return paragraphs |
| +} |
| + |
| +/** A ProseMirror doc for the fragment's paragraphs (what the stage binds). */ |
| +export function docFromParagraphs (paragraphs) { |
| + const nodes = paragraphs.map(p => schema.node('paragraph', null, p.map(r => { |
| + const marks = [] |
| + for (const k of MARK_KEYS) if (r[k]) marks.push(schema.mark(k)) |
| + if (r.href != null) marks.push(schema.mark('href', { href: r.href })) |
| + return schema.text(r.t, marks) |
| + }))) |
| + return schema.node('doc', null, nodes.length ? nodes : [schema.node('paragraph')]) |
| +} |
diff --git a/fixtures/vectors/canvas.view.json b/fixtures/vectors/canvas.view.json| new file mode 100644 |
| index 0000000..891c704 |
| --- /dev/null |
| +++ b/fixtures/vectors/canvas.view.json |
| @@ -0,0 +1,360 @@ |
| +{ |
| + "assets": { |
| + "assetbg22222": { |
| + "alt": "", |
| + "height": 1080, |
| + "name": "bg.webp", |
| + "ref": "blobs/2026/09/bg.webp", |
| + "width": 1920 |
| + } |
| + }, |
| + "meta": { |
| + "accent": null, |
| + "aspect": "16:9", |
| + "fonts": null, |
| + "theme": "ink", |
| + "title": "Canvas" |
| + }, |
| + "slides": [ |
| + { |
| + "background": { |
| + "kind": "image", |
| + "value": "assetbg22222" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": { |
| + "h": 0.62, |
| + "w": 0.88, |
| + "x": 0.06, |
| + "y": 0.3 |
| + }, |
| + "id": "blkfirst2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Inserted first, z from index" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.2, |
| + "w": 0.8, |
| + "x": 0.1, |
| + "y": 0.1235 |
| + }, |
| + "id": "blkheading22", |
| + "phone": { |
| + "box": { |
| + "h": 0.25, |
| + "w": 1, |
| + "x": 0, |
| + "y": 0 |
| + }, |
| + "size": "m" |
| + }, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Placed by hand" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 5 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.3333, |
| + "w": 0.4, |
| + "x": 0.05, |
| + "y": 0.5 |
| + }, |
| + "id": "blkdefault22", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Box from the body region" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 9 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.84, |
| + "w": 0.44, |
| + "x": 0.5, |
| + "y": 0.08 |
| + }, |
| + "id": "blkimage2222", |
| + "phone": {}, |
| + "props": { |
| + "alt": "", |
| + "asset": "assetbg22222", |
| + "fit": "contain" |
| + }, |
| + "region": null, |
| + "type": "image", |
| + "z": 2 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.2, |
| + "w": 0.2, |
| + "x": 0.7, |
| + "y": 0.7 |
| + }, |
| + "id": "blkshape2222", |
| + "phone": {}, |
| + "props": { |
| + "fill": "#f0f", |
| + "kind": "ellipse" |
| + }, |
| + "region": null, |
| + "text": [], |
| + "type": "shape", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldcanvas222", |
| + "layout": "canvas", |
| + "notes": [], |
| + "phone": "reflow", |
| + "template": "blank", |
| + "transition": "fade" |
| + }, |
| + { |
| + "background": { |
| + "kind": "image", |
| + "value": "assetbg22222" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": { |
| + "h": 0.62, |
| + "w": 0.88, |
| + "x": 0.06, |
| + "y": 0.3 |
| + }, |
| + "id": "blkcopy22222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Inserted first, z from index" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.2, |
| + "w": 0.8, |
| + "x": 0.1, |
| + "y": 0.1235 |
| + }, |
| + "id": "blkcopy22223", |
| + "phone": { |
| + "box": { |
| + "h": 0.25, |
| + "w": 1, |
| + "x": 0, |
| + "y": 0 |
| + }, |
| + "size": "m" |
| + }, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Edited on the copy only" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 5 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.3333, |
| + "w": 0.4, |
| + "x": 0.05, |
| + "y": 0.5 |
| + }, |
| + "id": "blkcopy22224", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Box from the body region" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 9 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.84, |
| + "w": 0.44, |
| + "x": 0.5, |
| + "y": 0.08 |
| + }, |
| + "id": "blkcopy22225", |
| + "phone": {}, |
| + "props": { |
| + "alt": "", |
| + "asset": "assetbg22222", |
| + "fit": "contain" |
| + }, |
| + "region": null, |
| + "type": "image", |
| + "z": 2 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.2, |
| + "w": 0.2, |
| + "x": 0.7, |
| + "y": 0.7 |
| + }, |
| + "id": "blkcopy22226", |
| + "phone": {}, |
| + "props": { |
| + "fill": "#f0f", |
| + "kind": "ellipse" |
| + }, |
| + "region": null, |
| + "text": [], |
| + "type": "shape", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldcopy22222", |
| + "layout": "canvas", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "blank", |
| + "transition": "fade" |
| + }, |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blktophead22", |
| + "phone": {}, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": "heading", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Top heading" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkleft22222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Left" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkright2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Right" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blklowhead22", |
| + "phone": {}, |
| + "props": { |
| + "level": 2 |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Low heading" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkpicture22", |
| + "phone": {}, |
| + "props": { |
| + "alt": "bg", |
| + "asset": "assetbg22222" |
| + }, |
| + "region": "media", |
| + "type": "image", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldtoflow222", |
| + "layout": "flow", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "two-column", |
| + "transition": "none" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/vectors/canvas.yjs b/fixtures/vectors/canvas.yjs| new file mode 100644 |
| index 0000000..42d40b2 |
| Binary files /dev/null and b/fixtures/vectors/canvas.yjs differ |
diff --git a/fixtures/vectors/columns.view.json b/fixtures/vectors/columns.view.json| new file mode 100644 |
| index 0000000..8488715 |
| --- /dev/null |
| +++ b/fixtures/vectors/columns.view.json |
| @@ -0,0 +1,311 @@ |
| +{ |
| + "assets": {}, |
| + "meta": { |
| + "accent": null, |
| + "aspect": "16:9", |
| + "fonts": null, |
| + "theme": "paper", |
| + "title": "Columns" |
| + }, |
| + "slides": [ |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": { |
| + "h": 0.18, |
| + "w": 0.88, |
| + "x": 0.06, |
| + "y": 0.08 |
| + }, |
| + "id": "blkheading22", |
| + "phone": {}, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Three up" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.155, |
| + "w": 0.44, |
| + "x": 0.06, |
| + "y": 0.3 |
| + }, |
| + "id": "blkcolumns22", |
| + "phone": {}, |
| + "props": { |
| + "children": [ |
| + "blkright2222", |
| + "blkmiddle222", |
| + "blkleft22222" |
| + ], |
| + "count": 3 |
| + }, |
| + "region": null, |
| + "type": "columns", |
| + "z": 1 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.155, |
| + "w": 0.44, |
| + "x": 0.06, |
| + "y": 0.455 |
| + }, |
| + "id": "blkleft22222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Left" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 2 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.155, |
| + "w": 0.44, |
| + "x": 0.06, |
| + "y": 0.61 |
| + }, |
| + "id": "blkmiddle222", |
| + "phone": {}, |
| + "props": { |
| + "style": "bullet" |
| + }, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "one" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "two" |
| + } |
| + ] |
| + ], |
| + "type": "list", |
| + "z": 3 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.155, |
| + "w": 0.44, |
| + "x": 0.06, |
| + "y": 0.765 |
| + }, |
| + "id": "blkright2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Right" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 4 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.62, |
| + "w": 0.4, |
| + "x": 0.54, |
| + "y": 0.3 |
| + }, |
| + "id": "blkaside2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": null, |
| + "text": [ |
| + [ |
| + { |
| + "t": "Aside" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 5 |
| + } |
| + ], |
| + "id": "sldcolumns22", |
| + "layout": "canvas", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "two-column", |
| + "transition": "none" |
| + }, |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blkcopyhead2", |
| + "phone": {}, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": "heading", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Three up" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcopyaside", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "aside", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Aside" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcopycols2", |
| + "phone": {}, |
| + "props": { |
| + "count": 2 |
| + }, |
| + "region": "body", |
| + "type": "columns", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcopyleft2", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Left" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcopyright", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Right" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldcopy22222", |
| + "layout": "flow", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "two-column", |
| + "transition": "none" |
| + }, |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blkbwhead222", |
| + "phone": {}, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": "heading", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Both ways" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkbwimage22", |
| + "phone": {}, |
| + "props": { |
| + "alt": "x" |
| + }, |
| + "region": "media", |
| + "type": "image", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkbwbody222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Body" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldbothways2", |
| + "layout": "flow", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "blank", |
| + "transition": "none" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/vectors/columns.yjs b/fixtures/vectors/columns.yjs| new file mode 100644 |
| index 0000000..275c915 |
| Binary files /dev/null and b/fixtures/vectors/columns.yjs differ |
diff --git a/fixtures/vectors/every-block.view.json b/fixtures/vectors/every-block.view.json| new file mode 100644 |
| index 0000000..997346e |
| --- /dev/null |
| +++ b/fixtures/vectors/every-block.view.json |
| @@ -0,0 +1,306 @@ |
| +{ |
| + "assets": { |
| + "assetphoto22": { |
| + "alt": "A harbour at dusk", |
| + "height": 900, |
| + "name": "photo.jpg", |
| + "ref": "blobs/2026/09/photo.jpg", |
| + "width": 1600 |
| + } |
| + }, |
| + "meta": { |
| + "accent": null, |
| + "aspect": "16:9", |
| + "fonts": null, |
| + "theme": "paper", |
| + "title": "Every block" |
| + }, |
| + "slides": [ |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blkspacer222", |
| + "phone": {}, |
| + "props": { |
| + "size": "l" |
| + }, |
| + "region": "body", |
| + "type": "spacer", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkheading22", |
| + "phone": {}, |
| + "props": { |
| + "level": 3 |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Every block" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkafter2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Inserted after the heading" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blktext22222", |
| + "phone": { |
| + "size": "l" |
| + }, |
| + "props": { |
| + "size": "m" |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Replaced " |
| + }, |
| + { |
| + "i": true, |
| + "t": "text" |
| + }, |
| + { |
| + "t": " with " |
| + }, |
| + { |
| + "href": "https://example.org", |
| + "t": "a link" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blklist22222", |
| + "phone": {}, |
| + "props": { |
| + "size": "s", |
| + "style": "number" |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "First" |
| + } |
| + ], |
| + [ |
| + { |
| + "i": true, |
| + "t": "Second" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "Third" |
| + } |
| + ] |
| + ], |
| + "type": "list", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkquote2222", |
| + "phone": {}, |
| + "props": { |
| + "cite": "Someone" |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "h": true, |
| + "t": "Quoted" |
| + }, |
| + { |
| + "t": " words" |
| + } |
| + ] |
| + ], |
| + "type": "quote", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkimage2222", |
| + "phone": { |
| + "hidden": true |
| + }, |
| + "props": { |
| + "alt": "A harbour at dusk", |
| + "asset": "assetphoto22", |
| + "fit": "cover", |
| + "radius": 8 |
| + }, |
| + "region": "media", |
| + "type": "image", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcoltext22", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Left" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcolumns22", |
| + "phone": {}, |
| + "props": { |
| + "children": [ |
| + "blkcoltext22" |
| + ], |
| + "count": 2 |
| + }, |
| + "region": "body", |
| + "type": "columns", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkcode22222", |
| + "phone": {}, |
| + "props": { |
| + "language": "go", |
| + "size": "s" |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "func main() {" |
| + } |
| + ], |
| + [], |
| + [ |
| + { |
| + "t": "\tfmt.Println(\"**not bold**\")" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "}" |
| + } |
| + ] |
| + ], |
| + "type": "code", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkshape2222", |
| + "phone": {}, |
| + "props": { |
| + "fill": "#eee", |
| + "kind": "rect", |
| + "radius": 4, |
| + "stroke": "#333", |
| + "strokeWidth": 2 |
| + }, |
| + "region": "aside", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Label" |
| + } |
| + ] |
| + ], |
| + "type": "shape", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blktable2222", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [], |
| + [], |
| + [], |
| + [], |
| + [] |
| + ], |
| + "cols": 3, |
| + "header": true, |
| + "rows": 2 |
| + }, |
| + "region": "body", |
| + "type": "table", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkdivider22", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "type": "divider", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blkfirst2222", |
| + "phone": {}, |
| + "props": {}, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "Inserted first" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldblocks222", |
| + "layout": "flow", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "title-body", |
| + "transition": "none" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/vectors/every-block.yjs b/fixtures/vectors/every-block.yjs| new file mode 100644 |
| index 0000000..1083666 |
| Binary files /dev/null and b/fixtures/vectors/every-block.yjs differ |
diff --git a/fixtures/vectors/table.view.json b/fixtures/vectors/table.view.json| new file mode 100644 |
| index 0000000..b666d97 |
| --- /dev/null |
| +++ b/fixtures/vectors/table.view.json |
| @@ -0,0 +1,156 @@ |
| +{ |
| + "assets": {}, |
| + "meta": { |
| + "accent": null, |
| + "aspect": "16:9", |
| + "fonts": null, |
| + "theme": "paper", |
| + "title": "Tables" |
| + }, |
| + "slides": [ |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": { |
| + "h": 0.3, |
| + "w": 0.8, |
| + "x": 0.1, |
| + "y": 0.1 |
| + }, |
| + "id": "blkcanvastab", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [], |
| + [] |
| + ], |
| + "cols": 3, |
| + "rows": 1 |
| + }, |
| + "region": null, |
| + "type": "table", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldcanvas222", |
| + "layout": "canvas", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "blank", |
| + "transition": "none" |
| + }, |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blkdefault22", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [] |
| + ], |
| + "cols": 1, |
| + "rows": 2 |
| + }, |
| + "region": "body", |
| + "type": "table", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blksized2222", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [], |
| + [], |
| + [] |
| + ], |
| + "cols": 1, |
| + "header": false, |
| + "rows": 4 |
| + }, |
| + "region": "body", |
| + "type": "table", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldtables222", |
| + "layout": "flow", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "title-body", |
| + "transition": "none" |
| + }, |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": { |
| + "h": 0.31, |
| + "w": 0.88, |
| + "x": 0.06, |
| + "y": 0.3 |
| + }, |
| + "id": "blkcopydef22", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [] |
| + ], |
| + "cols": 1, |
| + "rows": 2 |
| + }, |
| + "region": null, |
| + "type": "table", |
| + "z": 0 |
| + }, |
| + { |
| + "box": { |
| + "h": 0.31, |
| + "w": 0.88, |
| + "x": 0.06, |
| + "y": 0.61 |
| + }, |
| + "id": "blkcopysized", |
| + "phone": {}, |
| + "props": { |
| + "cells": [ |
| + [], |
| + [], |
| + [], |
| + [] |
| + ], |
| + "cols": 2, |
| + "header": false, |
| + "rows": 2 |
| + }, |
| + "region": null, |
| + "type": "table", |
| + "z": 1 |
| + } |
| + ], |
| + "id": "sldcopy22222", |
| + "layout": "canvas", |
| + "notes": [], |
| + "phone": "inherit", |
| + "template": "title-body", |
| + "transition": "none" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/vectors/table.yjs b/fixtures/vectors/table.yjs| new file mode 100644 |
| index 0000000..29eee19 |
| Binary files /dev/null and b/fixtures/vectors/table.yjs differ |
diff --git a/fixtures/vectors/title-only.view.json b/fixtures/vectors/title-only.view.json| new file mode 100644 |
| index 0000000..f9e5dab |
| --- /dev/null |
| +++ b/fixtures/vectors/title-only.view.json |
| @@ -0,0 +1,133 @@ |
| +{ |
| + "assets": {}, |
| + "meta": { |
| + "accent": null, |
| + "aspect": "16:9", |
| + "fonts": { |
| + "body": "Inter", |
| + "heading": "Fraunces" |
| + }, |
| + "theme": "paper", |
| + "title": "Quarterly review" |
| + }, |
| + "slides": [ |
| + { |
| + "background": { |
| + "kind": "theme", |
| + "value": "" |
| + }, |
| + "blocks": [ |
| + { |
| + "box": null, |
| + "id": "blkheading22", |
| + "phone": {}, |
| + "props": { |
| + "level": 1 |
| + }, |
| + "region": "heading", |
| + "text": [ |
| + [ |
| + { |
| + "b": true, |
| + "t": "Bold" |
| + }, |
| + { |
| + "t": " " |
| + }, |
| + { |
| + "i": true, |
| + "t": "italic" |
| + }, |
| + { |
| + "t": " " |
| + }, |
| + { |
| + "c": true, |
| + "t": "code" |
| + }, |
| + { |
| + "t": " " |
| + }, |
| + { |
| + "h": true, |
| + "t": "mark" |
| + }, |
| + { |
| + "t": " " |
| + }, |
| + { |
| + "href": "https://example.com/a?b=1", |
| + "t": "link" |
| + }, |
| + { |
| + "t": " * plain" |
| + } |
| + ] |
| + ], |
| + "type": "heading", |
| + "z": 0 |
| + }, |
| + { |
| + "box": null, |
| + "id": "blksubtitle2", |
| + "phone": {}, |
| + "props": { |
| + "align": "center", |
| + "size": "l" |
| + }, |
| + "region": "body", |
| + "text": [ |
| + [ |
| + { |
| + "t": "A line that wraps" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "Second paragraph with " |
| + }, |
| + { |
| + "b": true, |
| + "href": "https://example.com", |
| + "t": "bold link" |
| + } |
| + ] |
| + ], |
| + "type": "text", |
| + "z": 0 |
| + } |
| + ], |
| + "id": "sldtitle2222", |
| + "layout": "flow", |
| + "notes": [ |
| + [ |
| + { |
| + "t": "Say hello." |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "Thank the team" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "Mention " |
| + }, |
| + { |
| + "h": true, |
| + "t": "the number" |
| + } |
| + ], |
| + [ |
| + { |
| + "t": "Then pause" |
| + } |
| + ] |
| + ], |
| + "phone": "inherit", |
| + "template": "title", |
| + "transition": "none" |
| + } |
| + ] |
| +} |
diff --git a/fixtures/vectors/title-only.yjs b/fixtures/vectors/title-only.yjs| new file mode 100644 |
| index 0000000..69e41a1 |
| Binary files /dev/null and b/fixtures/vectors/title-only.yjs differ |
diff --git a/fixtures/view.mjs b/fixtures/view.mjs| new file mode 100644 |
| index 0000000..3782ddc |
| --- /dev/null |
| +++ b/fixtures/view.mjs |
| @@ -0,0 +1,70 @@ |
| +// The JSON view of a deck — core/src/view.rs, key for key. What the drive |
| +// API returns and what the renderer reads; the vectors compare this to |
| +// the core's own view of the same document as parsed JSON, so key order |
| +// does not matter here (generate.mjs sorts keys when it writes a file). |
| +// |
| +// Every key has a default so a deck a peer left half-written still views |
| +// as something: an absent layout is flow, an absent template blank, an |
| +// absent z is 0. A block's text key is present for text-bearing blocks |
| +// (any block that carries a text fragment) and absent otherwise; a |
| +// table's cells appear as props.cells, a row-major array of texts. |
| +import * as Y from 'yjs' |
| +import { roots, getString, getNumber, getArray, getFragment, getObject, mapsOf, outToJSON } from './ydoc.mjs' |
| +import { readText } from './schema.mjs' |
| + |
| +export const DEFAULT_THEME = 'paper' |
| +export const DEFAULT_ASPECT = '16:9' |
| +export const DEFAULT_TEMPLATE = 'blank' |
| + |
| +function blockView (m) { |
| + const props = getObject(m, 'props') ?? {} |
| + const cells = getArray(m, 'cells') |
| + if (cells !== null) { |
| + props.cells = cells.toArray().map(c => (c instanceof Y.XmlFragment && !(c instanceof Y.XmlElement) ? readText(c) : outToJSON(c))) |
| + } |
| + const block = { |
| + id: getString(m, 'id') ?? '', |
| + type: getString(m, 'type') ?? '', |
| + props, |
| + region: getString(m, 'region'), |
| + box: getObject(m, 'box'), |
| + z: getNumber(m, 'z') ?? 0, |
| + phone: getObject(m, 'phone') ?? {} |
| + } |
| + const text = getFragment(m, 'text') |
| + if (text !== null) block.text = readText(text) |
| + return block |
| +} |
| + |
| +function slideView (m) { |
| + const blocks = getArray(m, 'blocks') |
| + const notes = getFragment(m, 'notes') |
| + return { |
| + id: getString(m, 'id') ?? '', |
| + layout: getString(m, 'layout') ?? 'flow', |
| + template: getString(m, 'template') ?? DEFAULT_TEMPLATE, |
| + background: getObject(m, 'background') ?? { kind: 'theme', value: '' }, |
| + phone: getString(m, 'phone') ?? 'inherit', |
| + transition: getString(m, 'transition') ?? 'none', |
| + notes: notes !== null ? readText(notes) : [], |
| + blocks: blocks !== null ? mapsOf(blocks).map(([, b]) => blockView(b)) : [] |
| + } |
| +} |
| + |
| +/** The deck as JSON. Never empty: an untouched document views as the defaults. */ |
| +export function view (doc) { |
| + const r = roots(doc) |
| + const assets = {} |
| + r.assets.forEach((v, k) => { assets[k] = outToJSON(v) }) |
| + return { |
| + meta: { |
| + title: getString(r.meta, 'title') ?? '', |
| + theme: getString(r.meta, 'theme') ?? DEFAULT_THEME, |
| + accent: getString(r.meta, 'accent'), |
| + fonts: getObject(r.meta, 'fonts'), |
| + aspect: getString(r.meta, 'aspect') ?? DEFAULT_ASPECT |
| + }, |
| + slides: mapsOf(r.slides).map(([, m]) => slideView(m)), |
| + assets |
| + } |
| +} |
diff --git a/fixtures/ydoc.mjs b/fixtures/ydoc.mjs| new file mode 100644 |
| index 0000000..36fecb6 |
| --- /dev/null |
| +++ b/fixtures/ydoc.mjs |
| @@ -0,0 +1,110 @@ |
| +// The deck's shape over yjs: root names and the small set of accessors |
| +// ops, layout and view share — core/src/schema.rs's "roots and lookups" |
| +// and "JSON <-> yrs values" sections. Every accessor is as tolerant as |
| +// the core's: a key holding the wrong kind of value reads as absent, a |
| +// non-map entry in an array of maps is skipped, never trapped on. |
| +import * as Y from 'yjs' |
| +import { readText } from './schema.mjs' |
| + |
| +export const ROOT_META = 'meta' |
| +export const ROOT_SLIDES = 'slides' |
| +export const ROOT_ASSETS = 'assets' |
| + |
| +/** The three root types. */ |
| +export function roots (doc) { |
| + return { |
| + meta: doc.getMap(ROOT_META), |
| + slides: doc.getArray(ROOT_SLIDES), |
| + assets: doc.getMap(ROOT_ASSETS) |
| + } |
| +} |
| + |
| +export function getString (map, key) { |
| + const v = map.get(key) |
| + return typeof v === 'string' ? v : null |
| +} |
| + |
| +export function getNumber (map, key) { |
| + const v = map.get(key) |
| + if (typeof v === 'number') return v |
| + if (typeof v === 'bigint') return Number(v) |
| + return null |
| +} |
| + |
| +export function getMap (map, key) { |
| + const v = map.get(key) |
| + return v instanceof Y.Map ? v : null |
| +} |
| + |
| +export function getArray (map, key) { |
| + const v = map.get(key) |
| + return v instanceof Y.Array ? v : null |
| +} |
| + |
| +export function getFragment (map, key) { |
| + const v = map.get(key) |
| + return v instanceof Y.XmlFragment && !(v instanceof Y.XmlElement) ? v : null |
| +} |
| + |
| +/** The maps of an array of maps, in order, as [index, map] pairs. */ |
| +export function mapsOf (array) { |
| + const out = [] |
| + array.forEach((v, i) => { if (v instanceof Y.Map) out.push([i, v]) }) |
| + return out |
| +} |
| + |
| +/** The map in array whose id is id, as [index, map], or null. */ |
| +export function findById (array, id) { |
| + return mapsOf(array).find(([, m]) => getString(m, 'id') === id) ?? null |
| +} |
| + |
| +/** A plain object: a shared type rendered by content, a plain value as is. */ |
| +function isPlainObject (v) { |
| + return v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Y.AbstractType) |
| +} |
| + |
| +/** `Some(json)` when the value is a map (shared or plain), else null. */ |
| +export function getObject (map, key) { |
| + const v = map.get(key) |
| + if (v instanceof Y.Map) return mapToJSON(v) |
| + if (isPlainObject(v)) return anyToJSON(v) |
| + return null |
| +} |
| + |
| +export function anyToJSON (v) { |
| + if (v === undefined) return null |
| + if (typeof v === 'bigint') return Number(v) |
| + if (Array.isArray(v)) return v.map(anyToJSON) |
| + if (v instanceof Uint8Array) return Array.from(v) |
| + if (isPlainObject(v)) { |
| + const out = {} |
| + for (const k of Object.keys(v)) out[k] = anyToJSON(v[k]) |
| + return out |
| + } |
| + return v |
| +} |
| + |
| +/** |
| + * Any value the document holds, as JSON: a map as an object, an array |
| + * as an array, a text fragment as its paragraphs, a text as its string. |
| + */ |
| +export function outToJSON (v) { |
| + if (v instanceof Y.Map) return mapToJSON(v) |
| + if (v instanceof Y.Array) return v.toArray().map(outToJSON) |
| + if (v instanceof Y.XmlElement) return v.toString() |
| + if (v instanceof Y.XmlFragment) return readText(v) |
| + if (v instanceof Y.Text) return v.toString() |
| + if (v instanceof Y.AbstractType) return null |
| + return anyToJSON(v) |
| +} |
| + |
| +export function mapToJSON (map) { |
| + const out = {} |
| + map.forEach((v, k) => { out[k] = outToJSON(v) }) |
| + return out |
| +} |
| + |
| +/** A deep copy of a map ready to insert elsewhere (yrs's as_prelim). */ |
| +export function cloneMap (map) { |
| + return map.clone() |
| +} |
diff --git a/internal/slides/core/vectors_test.go b/internal/slides/core/vectors_test.go| new file mode 100644 |
| index 0000000..eba83b5 |
| --- /dev/null |
| +++ b/internal/slides/core/vectors_test.go |
| @@ -0,0 +1,186 @@ |
| +package core_test |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/slides/internal/slides/core" |
| +) |
| + |
| +// The contract between the browser and the core (spec §2.8): for every |
| +// vector fixtures/generate.mjs wrote, the core's view of the document the |
| +// JS side built must equal the JS side's own view, and the core's own |
| +// Ops applied to the same batch must arrive at that view as well. A |
| +// difference here is a bug in one implementation or the other, never |
| +// something to fix by editing a vector. |
| + |
| +const vectorsDir = "../../../fixtures/vectors" |
| +const dataDir = "../../../fixtures/data" |
| + |
| +func vectorNames(t *testing.T) []string { |
| + t.Helper() |
| + entries, err := filepath.Glob(filepath.Join(vectorsDir, "*.yjs")) |
| + if err != nil { |
| + t.Fatalf("glob: %v", err) |
| + } |
| + if len(entries) == 0 { |
| + t.Fatalf("no vectors under %s: run node generate.mjs in fixtures/", vectorsDir) |
| + } |
| + names := make([]string, 0, len(entries)) |
| + for _, e := range entries { |
| + names = append(names, strings.TrimSuffix(filepath.Base(e), ".yjs")) |
| + } |
| + sort.Strings(names) |
| + return names |
| +} |
| + |
| +// canonical parses JSON and re-encodes it with sorted keys and one entry |
| +// per line, so two views compare as values and a mismatch reads as a diff. |
| +func canonical(t *testing.T, raw []byte) string { |
| + t.Helper() |
| + var v any |
| + if err := json.Unmarshal(raw, &v); err != nil { |
| + t.Fatalf("not JSON: %v\n%s", err, raw) |
| + } |
| + var b strings.Builder |
| + writeCanonical(&b, v, "") |
| + return b.String() |
| +} |
| + |
| +func writeCanonical(b *strings.Builder, v any, indent string) { |
| + switch x := v.(type) { |
| + case map[string]any: |
| + keys := make([]string, 0, len(x)) |
| + for k := range x { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + b.WriteString("{\n") |
| + for _, k := range keys { |
| + fmt.Fprintf(b, "%s %q: ", indent, k) |
| + writeCanonical(b, x[k], indent+" ") |
| + b.WriteString("\n") |
| + } |
| + b.WriteString(indent + "}") |
| + case []any: |
| + b.WriteString("[\n") |
| + for _, item := range x { |
| + b.WriteString(indent + " ") |
| + writeCanonical(b, item, indent+" ") |
| + b.WriteString("\n") |
| + } |
| + b.WriteString(indent + "]") |
| + default: |
| + enc, _ := json.Marshal(x) |
| + b.Write(enc) |
| + } |
| +} |
| + |
| +// diffLines reports the first lines where want and got differ, with a |
| +// little context, so a failing vector says where rather than dumping |
| +// two whole views. |
| +func diffLines(want, got string) string { |
| + w := strings.Split(want, "\n") |
| + g := strings.Split(got, "\n") |
| + i := 0 |
| + for i < len(w) && i < len(g) && w[i] == g[i] { |
| + i++ |
| + } |
| + from := i - 3 |
| + if from < 0 { |
| + from = 0 |
| + } |
| + var b strings.Builder |
| + fmt.Fprintf(&b, "first difference at line %d\n", i+1) |
| + for j := from; j < i; j++ { |
| + fmt.Fprintf(&b, " %s\n", w[j]) |
| + } |
| + for j := i; j < i+6 && j < len(w); j++ { |
| + fmt.Fprintf(&b, "- %s\n", w[j]) |
| + } |
| + for j := i; j < i+6 && j < len(g); j++ { |
| + fmt.Fprintf(&b, "+ %s\n", g[j]) |
| + } |
| + return b.String() |
| +} |
| + |
| +func wantView(t *testing.T, name string) string { |
| + t.Helper() |
| + raw, err := os.ReadFile(filepath.Join(vectorsDir, name+".view.json")) |
| + if err != nil { |
| + t.Fatalf("read view: %v", err) |
| + } |
| + return canonical(t, raw) |
| +} |
| + |
| +func assertView(t *testing.T, ctx context.Context, h *core.Handle, want string) { |
| + t.Helper() |
| + view, err := h.JSON(ctx) |
| + if err != nil { |
| + t.Fatalf("JSON: %v", err) |
| + } |
| + got := canonical(t, view) |
| + if got != want { |
| + t.Errorf("the core's view differs from the JS view (- want, + got):\n%s", diffLines(want, got)) |
| + } |
| +} |
| + |
| +// TestVectorsViewAsTheJSSideViewsThem: each .yjs update, applied to a |
| +// fresh document, views exactly as the JS side that wrote it says. |
| +func TestVectorsViewAsTheJSSideViewsThem(t *testing.T) { |
| + ctx, rt := newRuntime(t) |
| + for _, name := range vectorNames(t) { |
| + t.Run(name, func(t *testing.T) { |
| + update, err := os.ReadFile(filepath.Join(vectorsDir, name+".yjs")) |
| + if err != nil { |
| + t.Fatalf("read update: %v", err) |
| + } |
| + h := newDoc(t, ctx, rt, name, 1) |
| + if err := h.Apply(ctx, update); err != nil { |
| + t.Fatalf("Apply: %v", err) |
| + } |
| + assertView(t, ctx, h, wantView(t, name)) |
| + }) |
| + } |
| +} |
| + |
| +// TestVectorsOpsAgreeWithTheJSOps: the same batch through the core's own |
| +// Ops arrives at the same view, so the two implementations of the §7.2 |
| +// vocabulary are one vocabulary. |
| +func TestVectorsOpsAgreeWithTheJSOps(t *testing.T) { |
| + ctx, rt := newRuntime(t) |
| + for _, name := range vectorNames(t) { |
| + t.Run(name, func(t *testing.T) { |
| + raw, err := os.ReadFile(filepath.Join(dataDir, name+".json")) |
| + if err != nil { |
| + t.Fatalf("read data: %v", err) |
| + } |
| + var data struct { |
| + GUID string `json:"guid"` |
| + Ops json.RawMessage `json:"ops"` |
| + } |
| + if err := json.Unmarshal(raw, &data); err != nil { |
| + t.Fatalf("parse data: %v", err) |
| + } |
| + if data.GUID != name { |
| + t.Fatalf("guid %q does not match the file name %q", data.GUID, name) |
| + } |
| + h := newDoc(t, ctx, rt, name, 1) |
| + update, err := h.Ops(ctx, data.Ops) |
| + if err != nil { |
| + t.Fatalf("Ops: %v", err) |
| + } |
| + if len(bytes.TrimSpace(update)) == 0 { |
| + t.Fatal("Ops produced an empty update") |
| + } |
| + assertView(t, ctx, h, wantView(t, name)) |
| + }) |
| + } |
| +} |