@abraca/convert 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ import * as Y from "yjs";
2
+
3
+ //#region packages/convert/src/types.d.ts
4
+ interface DocPageMeta extends Record<string, unknown> {
5
+ color?: string;
6
+ icon?: string;
7
+ datetimeStart?: string;
8
+ datetimeEnd?: string;
9
+ allDay?: boolean;
10
+ dateTaken?: string;
11
+ dateStart?: string;
12
+ dateEnd?: string;
13
+ timeStart?: string;
14
+ timeEnd?: string;
15
+ taskProgress?: number;
16
+ members?: {
17
+ id: string;
18
+ label: string;
19
+ }[];
20
+ tags?: string[];
21
+ checked?: boolean;
22
+ priority?: number;
23
+ status?: string;
24
+ rating?: number;
25
+ url?: string;
26
+ email?: string;
27
+ phone?: string;
28
+ number?: number;
29
+ unit?: string;
30
+ subtitle?: string;
31
+ note?: string;
32
+ coverUploadId?: string;
33
+ coverDocId?: string;
34
+ coverMimeType?: string;
35
+ geoType?: 'marker' | 'line' | 'measure';
36
+ geoLat?: number;
37
+ geoLng?: number;
38
+ geoDescription?: string;
39
+ deskX?: number;
40
+ deskY?: number;
41
+ deskZ?: number;
42
+ deskMode?: string;
43
+ mmX?: number;
44
+ mmY?: number;
45
+ graphX?: number;
46
+ graphY?: number;
47
+ graphPinned?: boolean;
48
+ spX?: number;
49
+ spY?: number;
50
+ spZ?: number;
51
+ spRX?: number;
52
+ spRY?: number;
53
+ spRZ?: number;
54
+ spSX?: number;
55
+ spSY?: number;
56
+ spSZ?: number;
57
+ spShape?: string;
58
+ spOpacity?: number;
59
+ spModelUploadId?: string;
60
+ spModelDocId?: string;
61
+ slidesTransition?: 'none' | 'fade' | 'slide';
62
+ slidesTheme?: 'dark' | 'light';
63
+ __schemaVersion?: number;
64
+ }
65
+ //#endregion
66
+ //#region packages/convert/src/markdown-to-yjs.d.ts
67
+ /**
68
+ * Converts a filename (without extension) to a human-readable label.
69
+ *
70
+ * - `this-is-a-doc` → `"This is a doc"` (kebab/snake: sentence case)
71
+ * - `ThisIsADoc` → `"This Is A Doc"` (PascalCase: preserves word caps)
72
+ * - `thisIsADoc` → `"This Is A Doc"` (camelCase: preserves word caps)
73
+ */
74
+ declare function filenameToLabel(raw: string): string;
75
+ interface FrontmatterResult {
76
+ title?: string;
77
+ type?: string;
78
+ meta: Partial<DocPageMeta>;
79
+ body: string;
80
+ }
81
+ declare function parseFrontmatter(markdown: string): FrontmatterResult;
82
+ /**
83
+ * Parses markdown text and writes the result into a Y.XmlFragment that
84
+ * TipTap's Collaboration extension can read.
85
+ *
86
+ * Requires `fragment.doc` to be set (i.e. the fragment must already be
87
+ * obtained from a live Y.Doc via `ydoc.getXmlFragment('default')`).
88
+ *
89
+ * @param fragment The target `Y.Doc.getXmlFragment('default')`
90
+ * @param markdown Raw markdown string
91
+ * @param fallbackTitle Used as the title when the markdown has no H1
92
+ */
93
+ declare function populateYDocFromMarkdown(fragment: Y.XmlFragment, markdown: string, fallbackTitle?: string): void;
94
+ //#endregion
95
+ //#region packages/convert/src/yjs-to-markdown.d.ts
96
+ declare function yjsToMarkdown(fragment: Y.XmlFragment, label: string, meta?: DocPageMeta, type?: string): string;
97
+ /**
98
+ * Walk the Y.XmlFragment and concatenate all text content with no
99
+ * markup or frontmatter. Block boundaries become newlines. Useful for
100
+ * accessibility tooling, search indexing, and snippet previews.
101
+ */
102
+ declare function yjsToPlainText(fragment: Y.XmlFragment): string;
103
+ declare function yjsToHtml(fragment: Y.XmlFragment, label: string): string;
104
+ //#endregion
105
+ //#region packages/convert/src/html-to-yjs.d.ts
106
+ /**
107
+ * Parses an HTML string and writes the result into a Y.XmlFragment that
108
+ * TipTap's Collaboration extension can read.
109
+ *
110
+ * @param fragment The target `Y.Doc.getXmlFragment('default')`
111
+ * @param html Raw HTML string
112
+ * @param fallbackTitle Used when no <title> or <h1> is found
113
+ */
114
+ declare function populateYDocFromHtml(fragment: Y.XmlFragment, html: string, fallbackTitle?: string): void;
115
+ /**
116
+ * Appends blocks from an HTML string to an existing Y.XmlFragment.
117
+ * Does NOT insert documentHeader/documentMeta — for appending to an existing doc.
118
+ */
119
+ declare function appendHtmlToFragment(fragment: Y.XmlFragment, html: string): void;
120
+ //#endregion
121
+ //#region packages/convert/src/diff.d.ts
122
+ type JsonAttr = string | number | boolean | null | JsonAttr[] | {
123
+ [key: string]: JsonAttr;
124
+ };
125
+ interface YjsTextRun {
126
+ insert: string;
127
+ attributes?: Record<string, JsonAttr>;
128
+ }
129
+ interface YjsJsonText {
130
+ kind: 'text';
131
+ runs: YjsTextRun[];
132
+ }
133
+ interface YjsJsonElement {
134
+ kind: 'element';
135
+ tag: string;
136
+ attrs?: Record<string, JsonAttr>;
137
+ children: YjsJsonNode[];
138
+ }
139
+ type YjsJsonNode = YjsJsonElement | YjsJsonText;
140
+ declare function yfragmentToJson(frag: Y.XmlFragment): YjsJsonElement;
141
+ /**
142
+ * Build a Y.XmlFragment from a previously captured golden JSON. The
143
+ * returned fragment is bound to a fresh Y.Doc so consumers can hand
144
+ * it to serialisers immediately. All Yjs mutations happen inside a
145
+ * single transaction.
146
+ */
147
+ declare function jsonToYFragment(root: YjsJsonElement, doc?: Y.Doc, key?: string): Y.XmlFragment;
148
+ interface YjsDiff {
149
+ equal: boolean;
150
+ /** Dotted path to the first divergence (empty when equal). */
151
+ path: string;
152
+ /** Short human-readable explanation. */
153
+ reason: string;
154
+ }
155
+ declare function diffYjs(a: Y.XmlFragment, b: Y.XmlFragment): YjsDiff;
156
+ declare function diffJson(a: YjsJsonNode, b: YjsJsonNode): YjsDiff;
157
+ /** Stringify a YjsJsonNode with sorted keys for byte-stable golden files. */
158
+ declare function stringifyJsonNode(node: YjsJsonNode): string;
159
+ //#endregion
160
+ //#region packages/convert/src/spec/nodes.d.ts
161
+ type NodeWireKind = 'vanilla' | 'fence' | 'mdc-container' | 'mdc-slotted' | 'mdc-atom-block' | 'mdc-atom-inl' | 'special';
162
+ interface NodeAttrSpec {
163
+ /** Attribute name on the Y.XmlElement and on the MDC prop. */
164
+ key: string;
165
+ /** Wire type — controls how the attr is parsed/serialised in props. */
166
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'json';
167
+ /** Optional default value omitted from serialised output when equal. */
168
+ default?: unknown;
169
+ /** Allowed values for enum-like string attrs. */
170
+ values?: readonly string[];
171
+ /** Whether the attr is allowed to be omitted entirely. */
172
+ optional?: boolean;
173
+ }
174
+ interface NodeSpec {
175
+ /** Y.XmlElement nodeName. */
176
+ name: string;
177
+ /** Block vs inline. */
178
+ group: 'block' | 'inline';
179
+ /** Wire form on Markdown. */
180
+ wire: NodeWireKind;
181
+ /**
182
+ * For slotted containers: name of the child element used as a slot
183
+ * (e.g. `accordion-item` under `accordion`).
184
+ */
185
+ slotChild?: string;
186
+ /** Declared attributes. */
187
+ attrs?: readonly NodeAttrSpec[];
188
+ /**
189
+ * For `mdc-container` and `mdc-slotted`: what MDC tag name to emit.
190
+ * Defaults to `name` (kebab-cased — `accordionItem` → `accordion-item`).
191
+ */
192
+ mdcTag?: string;
193
+ /**
194
+ * Whether the node is content-bearing (paragraph, listItem, etc.)
195
+ * or a child wrapper (tableRow, tableHeader).
196
+ */
197
+ contentBearing?: boolean;
198
+ doc?: string;
199
+ }
200
+ declare const NODE_SPECS: readonly NodeSpec[];
201
+ declare const NODE_SPEC_BY_NAME: ReadonlyMap<string, NodeSpec>;
202
+ /** Derive the MDC tag for a node — `mdcTag` override or kebab-case of `name`. */
203
+ declare function mdcTagOf(spec: NodeSpec): string;
204
+ //#endregion
205
+ //#region packages/convert/src/spec/marks.d.ts
206
+ type MarkWireKind = 'delimited' | 'link' | 'mdc-span';
207
+ interface MarkAttrSpec {
208
+ key: string;
209
+ type: 'string' | 'number' | 'boolean';
210
+ optional?: boolean;
211
+ }
212
+ interface MarkSpec {
213
+ name: string;
214
+ wire: MarkWireKind;
215
+ /** For `delimited`: the markdown delimiter (e.g. `**`, `*`, `~~`, `` ` ``, `__`, `==`). */
216
+ delim?: string;
217
+ attrs?: readonly MarkAttrSpec[];
218
+ doc?: string;
219
+ }
220
+ declare const MARK_SPECS: readonly MarkSpec[];
221
+ declare const MARK_SPEC_BY_NAME: ReadonlyMap<string, MarkSpec>;
222
+ declare const MARK_SPEC_BY_DELIM: ReadonlyMap<string, MarkSpec>;
223
+ //#endregion
224
+ //#region packages/convert/src/spec/universal-meta.d.ts
225
+ type MetaValueType = 'string' | 'number' | 'integer' | 'boolean' | 'string[]' | 'string-enum' | 'iso-date' | 'iso-datetime' | 'hh-mm' | 'members' | 'json';
226
+ interface UniversalMetaKey {
227
+ key: string;
228
+ type: MetaValueType;
229
+ /** Allowed values for `string-enum`. */
230
+ values?: readonly string[];
231
+ /** Inclusive minimum for `number` / `integer`. */
232
+ min?: number;
233
+ /** Inclusive maximum for `number` / `integer`. */
234
+ max?: number;
235
+ /**
236
+ * Alternate YAML key names recognised on parse — for backwards
237
+ * compatibility with hand-written frontmatter (e.g. `date` → `dateStart`).
238
+ * Serialise always uses the canonical `key`.
239
+ */
240
+ parseAliases?: readonly string[];
241
+ /** Human-readable hint, surfaced in documentation. */
242
+ doc?: string;
243
+ }
244
+ declare const UNIVERSAL_META_KEYS: readonly UniversalMetaKey[];
245
+ declare const UNIVERSAL_META_KEY_NAMES: ReadonlySet<string>;
246
+ /**
247
+ * Build a map of every recognised input key (canonical + aliases) to
248
+ * its canonical key. Used by the frontmatter parser.
249
+ */
250
+ declare function buildAliasMap(): ReadonlyMap<string, string>;
251
+ //#endregion
252
+ //#region packages/convert/src/file-blocks/manifest.d.ts
253
+ interface FsAdapter {
254
+ readTextFile: (path: string) => Promise<string>;
255
+ writeTextFile: (path: string, contents: string) => Promise<void>;
256
+ mkdir: (path: string, options?: {
257
+ recursive?: boolean;
258
+ }) => Promise<void>;
259
+ readBinaryFile?: (path: string) => Promise<Uint8Array>;
260
+ writeBinaryFile?: (path: string, contents: Uint8Array) => Promise<void>;
261
+ exists?: (path: string) => Promise<boolean>;
262
+ remove?: (path: string) => Promise<void>;
263
+ }
264
+ /** Install a filesystem adapter. Call this once at boot. */
265
+ declare function setFsAdapter(adapter: FsAdapter): void;
266
+ /** Read back the active adapter — useful for tests and for layered code. */
267
+ declare function getFsAdapter(): FsAdapter | null;
268
+ interface UploadManifestEntry {
269
+ uploadId: string;
270
+ filename: string;
271
+ relativePath: string;
272
+ contentHash: string;
273
+ }
274
+ interface ManifestEntry {
275
+ docId: string;
276
+ relativePath: string;
277
+ contentHash: string;
278
+ lastWrittenAt: number;
279
+ lastReadAt: number;
280
+ uploads: UploadManifestEntry[];
281
+ }
282
+ interface FsSyncManifest {
283
+ version: 2;
284
+ spaceId: string;
285
+ lastSyncAt: number;
286
+ entries: Record<string, ManifestEntry>;
287
+ }
288
+ declare function manifestDir(syncDir: string): string;
289
+ declare function trashDir(syncDir: string): string;
290
+ declare function orphansDir(syncDir: string): string;
291
+ declare function conflictsDir(syncDir: string): string;
292
+ declare function createEmptyManifest(spaceId: string): FsSyncManifest;
293
+ declare function loadManifest(syncDir: string, spaceId: string): Promise<FsSyncManifest>;
294
+ declare function saveManifest(syncDir: string, manifest: FsSyncManifest): Promise<void>;
295
+ declare function lookupByDocId(manifest: FsSyncManifest, docId: string): ManifestEntry | undefined;
296
+ declare function lookupByPath(manifest: FsSyncManifest, relativePath: string): ManifestEntry | undefined;
297
+ declare function lookupByHash(manifest: FsSyncManifest, contentHash: string): ManifestEntry | undefined;
298
+ declare function setEntry(manifest: FsSyncManifest, entry: ManifestEntry): void;
299
+ declare function removeEntry(manifest: FsSyncManifest, docId: string): ManifestEntry | undefined;
300
+ declare function buildReverseLookup(manifest: FsSyncManifest): Map<string, string>;
301
+ //#endregion
302
+ //#region packages/convert/src/file-blocks/paths.d.ts
303
+ interface FsTreeEntry {
304
+ label: string;
305
+ parentId: string | null;
306
+ order: number;
307
+ type?: string;
308
+ meta?: any;
309
+ }
310
+ /**
311
+ * Convert a document label to a filesystem-safe filename (without extension).
312
+ * e.g. "My Project!" -> "my-project"
313
+ */
314
+ declare function labelToFilename(label: string): string;
315
+ /**
316
+ * Convert a filename back to a label (best-effort).
317
+ * e.g. "my-project" -> "my project", "my-project~a3f2" -> "my project"
318
+ */
319
+ declare function fsFilenameToLabel(filename: string): string;
320
+ /**
321
+ * Check if a doc has children in the tree (needs _index.md convention).
322
+ */
323
+ declare function hasChildren(docId: string, treeData: Record<string, FsTreeEntry>): boolean;
324
+ /**
325
+ * Build the relative path for a document (from syncDir root).
326
+ * Handles:
327
+ * - Ancestor chain walking
328
+ * - _index.md for docs with children
329
+ * - Collision resolution via ~XXXX suffix
330
+ */
331
+ declare function buildRelativePath(docId: string, treeData: Record<string, FsTreeEntry>, manifest: FsSyncManifest): string;
332
+ /**
333
+ * Get the directory portion of a relative path for a doc.
334
+ * For _index.md docs: returns the directory containing _index.md
335
+ * For leaf docs: returns the parent directory
336
+ */
337
+ declare function getDocDir(relativePath: string): string;
338
+ /**
339
+ * Determine the parent docId from a filesystem relative path by walking the
340
+ * path segments and matching against the tree.
341
+ */
342
+ declare function resolveParentFromPath(relativePath: string, treeData: Record<string, FsTreeEntry>): string | null;
343
+ /**
344
+ * Simple string hash (same as current useFsSync).
345
+ */
346
+ declare function simpleHash(str: string): string;
347
+ /**
348
+ * Get all tree data as a flat record.
349
+ */
350
+ declare function getTreeData(treeMap: any): Record<string, FsTreeEntry>;
351
+ /**
352
+ * Find the next order value for a given parent (max sibling order + 1).
353
+ */
354
+ declare function nextOrder(treeData: Record<string, FsTreeEntry>, parentId: string | null): number;
355
+ //#endregion
356
+ export { type DocPageMeta, type FrontmatterResult, type FsAdapter, type FsSyncManifest, type FsTreeEntry, type JsonAttr, MARK_SPECS, MARK_SPEC_BY_DELIM, MARK_SPEC_BY_NAME, type ManifestEntry, type MarkAttrSpec, type MarkSpec, type MarkWireKind, type MetaValueType, NODE_SPECS, NODE_SPEC_BY_NAME, type NodeAttrSpec, type NodeSpec, type NodeWireKind, UNIVERSAL_META_KEYS, UNIVERSAL_META_KEY_NAMES, type UniversalMetaKey, type UploadManifestEntry, type YjsDiff, type YjsJsonElement, type YjsJsonNode, type YjsJsonText, type YjsTextRun, appendHtmlToFragment, buildAliasMap, buildRelativePath, buildReverseLookup, conflictsDir, createEmptyManifest, diffJson, diffYjs, filenameToLabel, fsFilenameToLabel, getDocDir, getFsAdapter, getTreeData, hasChildren, jsonToYFragment, labelToFilename, loadManifest, lookupByDocId, lookupByHash, lookupByPath, manifestDir, mdcTagOf, nextOrder, orphansDir, parseFrontmatter, populateYDocFromHtml, populateYDocFromMarkdown, removeEntry, resolveParentFromPath, saveManifest, setEntry, setFsAdapter, simpleHash, stringifyJsonNode, trashDir, yfragmentToJson, yjsToHtml, yjsToMarkdown, yjsToPlainText };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@abraca/convert",
3
+ "version": "2.3.0",
4
+ "description": "Bullet-proof Markdown ↔ Yjs/TipTap round-trip — canonical converter shared by cou-sh, abracadabra-nuxt, and @abraca/mcp.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/abracadabra-convert.cjs",
8
+ "module": "dist/abracadabra-convert.esm.js",
9
+ "types": "dist/index.d.ts",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "exports": {
14
+ "source": {
15
+ "import": "./src/index.ts"
16
+ },
17
+ "default": {
18
+ "import": "./dist/abracadabra-convert.esm.js",
19
+ "require": "./dist/abracadabra-convert.cjs",
20
+ "types": "./dist/index.d.ts"
21
+ }
22
+ },
23
+ "files": [
24
+ "src",
25
+ "dist"
26
+ ],
27
+ "peerDependencies": {
28
+ "yjs": "^13.6.8"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@tauri-apps/plugin-fs": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "yjs": "^13.6.8"
37
+ },
38
+ "scripts": {
39
+ "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/**/*.test.ts'"
40
+ }
41
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,302 @@
1
+ // Structural Y.XmlFragment ↔ JSON converter, used by the test harness
2
+ // to make round-trip assertions and to author golden fixtures by hand.
3
+ //
4
+ // The JSON shape is canonical:
5
+ //
6
+ // YjsJsonNode =
7
+ // | { kind: 'element'; tag: string; attrs?: Record<string, JsonAttr>; children: YjsJsonNode[] }
8
+ // | { kind: 'text'; runs: Array<{ insert: string; attributes?: Record<string, JsonAttr> }> }
9
+ //
10
+ // Attributes are stored verbatim — strings, numbers, booleans, null,
11
+ // nested JSON (for things like codeTree's `files`). The format is
12
+ // committed alongside fixtures as `.y.json` so reviews of intent
13
+ // changes are diffable.
14
+ //
15
+ // `yfragmentToJson(frag)` is deterministic: the same Y.XmlFragment
16
+ // always produces byte-identical JSON when stringified with sorted
17
+ // keys.
18
+
19
+ import * as Y from 'yjs'
20
+
21
+ export type JsonAttr =
22
+ | string
23
+ | number
24
+ | boolean
25
+ | null
26
+ | JsonAttr[]
27
+ | { [key: string]: JsonAttr }
28
+
29
+ export interface YjsTextRun {
30
+ insert: string
31
+ attributes?: Record<string, JsonAttr>
32
+ }
33
+
34
+ export interface YjsJsonText {
35
+ kind: 'text'
36
+ runs: YjsTextRun[]
37
+ }
38
+
39
+ export interface YjsJsonElement {
40
+ kind: 'element'
41
+ tag: string
42
+ attrs?: Record<string, JsonAttr>
43
+ children: YjsJsonNode[]
44
+ }
45
+
46
+ export type YjsJsonNode = YjsJsonElement | YjsJsonText
47
+
48
+ // ── Yjs → JSON ──────────────────────────────────────────────────────────────
49
+
50
+ function attrsToJson(el: Y.XmlElement): Record<string, JsonAttr> | undefined {
51
+ const out: Record<string, JsonAttr> = {}
52
+ let count = 0
53
+ for (const key of Object.keys(el.getAttributes())) {
54
+ const raw: unknown = el.getAttribute(key)
55
+ out[key] = normaliseAttr(raw)
56
+ count++
57
+ }
58
+ if (count === 0) return undefined
59
+ return sortKeys(out)
60
+ }
61
+
62
+ function normaliseAttr(v: unknown): JsonAttr {
63
+ if (v == null) return null
64
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
65
+ if (Array.isArray(v)) return v.map(normaliseAttr)
66
+ if (typeof v === 'object') {
67
+ const out: Record<string, JsonAttr> = {}
68
+ for (const k of Object.keys(v as object).sort()) {
69
+ out[k] = normaliseAttr((v as Record<string, unknown>)[k])
70
+ }
71
+ return out
72
+ }
73
+ // Functions / symbols / undefined — coerce to null so the JSON
74
+ // remains valid and diffs surface the loss.
75
+ return null
76
+ }
77
+
78
+ function sortKeys(obj: Record<string, JsonAttr>): Record<string, JsonAttr> {
79
+ const out: Record<string, JsonAttr> = {}
80
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k]!
81
+ return out
82
+ }
83
+
84
+ function textToJson(text: Y.XmlText): YjsJsonText {
85
+ const delta = text.toDelta() as Array<{ insert: unknown, attributes?: Record<string, unknown> }>
86
+ const runs: YjsTextRun[] = []
87
+ for (const op of delta) {
88
+ if (typeof op.insert !== 'string') continue
89
+ const run: YjsTextRun = { insert: op.insert }
90
+ if (op.attributes && Object.keys(op.attributes).length > 0) {
91
+ const normalised: Record<string, JsonAttr> = {}
92
+ for (const k of Object.keys(op.attributes).sort()) {
93
+ normalised[k] = normaliseAttr(op.attributes[k])
94
+ }
95
+ run.attributes = normalised
96
+ }
97
+ runs.push(run)
98
+ }
99
+ return { kind: 'text', runs }
100
+ }
101
+
102
+ function elementToJson(el: Y.XmlElement): YjsJsonElement {
103
+ const attrs = attrsToJson(el)
104
+ const children: YjsJsonNode[] = []
105
+ for (const child of el.toArray()) {
106
+ if (child instanceof Y.XmlElement) children.push(elementToJson(child))
107
+ else if (child instanceof Y.XmlText) children.push(textToJson(child))
108
+ }
109
+ const out: YjsJsonElement = { kind: 'element', tag: el.nodeName, children }
110
+ if (attrs) out.attrs = attrs
111
+ return out
112
+ }
113
+
114
+ export function yfragmentToJson(frag: Y.XmlFragment): YjsJsonElement {
115
+ const children: YjsJsonNode[] = []
116
+ for (const child of frag.toArray()) {
117
+ if (child instanceof Y.XmlElement) children.push(elementToJson(child))
118
+ else if (child instanceof Y.XmlText) children.push(textToJson(child))
119
+ }
120
+ return { kind: 'element', tag: '#fragment', children }
121
+ }
122
+
123
+ // ── JSON → Yjs ──────────────────────────────────────────────────────────────
124
+
125
+ // Yjs rule: every Y.XmlElement must be attached to its parent BEFORE
126
+ // its attributes are set or its children inserted. Otherwise the runtime
127
+ // logs "Invalid access: Add Yjs type to a document before reading data."
128
+ // and silently no-ops the mutation.
129
+ //
130
+ // So we do this in two passes per node: (1) create empty skeleton +
131
+ // attach to parent; (2) populate attrs + recurse on children, which
132
+ // each follow the same pattern. cou-sh's populateYDocFromMarkdown uses
133
+ // the same pattern.
134
+
135
+ function populateElement(el: Y.XmlElement, node: YjsJsonElement): void {
136
+ if (node.attrs) {
137
+ for (const k of Object.keys(node.attrs)) {
138
+ // Y.XmlElement.setAttribute only types `string`, but accepts
139
+ // arbitrary values in practice (numbers, booleans, JSON objects).
140
+ const v = node.attrs[k]
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ el.setAttribute(k, v as any)
143
+ }
144
+ }
145
+ for (const child of node.children) {
146
+ if (child.kind === 'element') {
147
+ const sub = new Y.XmlElement(child.tag)
148
+ el.insert(el.length, [sub])
149
+ populateElement(sub, child)
150
+ }
151
+ else {
152
+ const text = new Y.XmlText()
153
+ el.insert(el.length, [text])
154
+ populateText(text, child)
155
+ }
156
+ }
157
+ }
158
+
159
+ function populateText(text: Y.XmlText, node: YjsJsonText): void {
160
+ let offset = 0
161
+ for (const run of node.runs) {
162
+ if (run.attributes) {
163
+ text.insert(offset, run.insert, run.attributes as unknown as object)
164
+ }
165
+ else {
166
+ text.insert(offset, run.insert)
167
+ }
168
+ offset += run.insert.length
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Build a Y.XmlFragment from a previously captured golden JSON. The
174
+ * returned fragment is bound to a fresh Y.Doc so consumers can hand
175
+ * it to serialisers immediately. All Yjs mutations happen inside a
176
+ * single transaction.
177
+ */
178
+ export function jsonToYFragment(root: YjsJsonElement, doc?: Y.Doc, key = 'body'): Y.XmlFragment {
179
+ if (root.tag !== '#fragment') {
180
+ throw new Error(`jsonToYFragment expected tag="#fragment", got "${root.tag}"`)
181
+ }
182
+ const ydoc = doc ?? new Y.Doc()
183
+ const frag = ydoc.getXmlFragment(key)
184
+ ydoc.transact(() => {
185
+ for (const child of root.children) {
186
+ if (child.kind === 'element') {
187
+ const sub = new Y.XmlElement(child.tag)
188
+ frag.insert(frag.length, [sub])
189
+ populateElement(sub, child)
190
+ }
191
+ else {
192
+ const text = new Y.XmlText()
193
+ frag.insert(frag.length, [text])
194
+ populateText(text, child)
195
+ }
196
+ }
197
+ })
198
+ return frag
199
+ }
200
+
201
+ // ── Diff (deep equality with a path) ────────────────────────────────────────
202
+
203
+ export interface YjsDiff {
204
+ equal: boolean
205
+ /** Dotted path to the first divergence (empty when equal). */
206
+ path: string
207
+ /** Short human-readable explanation. */
208
+ reason: string
209
+ }
210
+
211
+ export function diffYjs(a: Y.XmlFragment, b: Y.XmlFragment): YjsDiff {
212
+ return diffNode(yfragmentToJson(a), yfragmentToJson(b), '')
213
+ }
214
+
215
+ export function diffJson(a: YjsJsonNode, b: YjsJsonNode): YjsDiff {
216
+ return diffNode(a, b, '')
217
+ }
218
+
219
+ function diffNode(a: YjsJsonNode, b: YjsJsonNode, path: string): YjsDiff {
220
+ if (a.kind !== b.kind) {
221
+ return { equal: false, path, reason: `kind mismatch: ${a.kind} vs ${b.kind}` }
222
+ }
223
+ if (a.kind === 'text' && b.kind === 'text') {
224
+ const ar = a.runs
225
+ const br = b.runs
226
+ if (ar.length !== br.length) {
227
+ return { equal: false, path, reason: `text run count: ${ar.length} vs ${br.length}` }
228
+ }
229
+ for (let i = 0; i < ar.length; i++) {
230
+ const sub = `${path}/runs[${i}]`
231
+ const ai = ar[i]!
232
+ const bi = br[i]!
233
+ if (ai.insert !== bi.insert) {
234
+ return { equal: false, path: sub, reason: `insert: ${JSON.stringify(ai.insert)} vs ${JSON.stringify(bi.insert)}` }
235
+ }
236
+ const attrDiff = diffAttrs(ai.attributes, bi.attributes, sub)
237
+ if (!attrDiff.equal) return attrDiff
238
+ }
239
+ return { equal: true, path: '', reason: '' }
240
+ }
241
+ if (a.kind === 'element' && b.kind === 'element') {
242
+ if (a.tag !== b.tag) return { equal: false, path, reason: `tag: ${a.tag} vs ${b.tag}` }
243
+ const attrDiff = diffAttrs(a.attrs, b.attrs, path)
244
+ if (!attrDiff.equal) return attrDiff
245
+ if (a.children.length !== b.children.length) {
246
+ return { equal: false, path, reason: `child count: ${a.children.length} vs ${b.children.length}` }
247
+ }
248
+ for (let i = 0; i < a.children.length; i++) {
249
+ const sub = path === '' ? `[${i}]` : `${path}[${i}]`
250
+ const childDiff = diffNode(a.children[i]!, b.children[i]!, sub)
251
+ if (!childDiff.equal) return childDiff
252
+ }
253
+ return { equal: true, path: '', reason: '' }
254
+ }
255
+ return { equal: false, path, reason: 'unreachable kind combination' }
256
+ }
257
+
258
+ function diffAttrs(
259
+ a: Record<string, JsonAttr> | undefined,
260
+ b: Record<string, JsonAttr> | undefined,
261
+ path: string,
262
+ ): YjsDiff {
263
+ const ak = a ? Object.keys(a).sort() : []
264
+ const bk = b ? Object.keys(b).sort() : []
265
+ if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) {
266
+ return { equal: false, path: `${path}.attrs`, reason: `attr keys: [${ak.join(',')}] vs [${bk.join(',')}]` }
267
+ }
268
+ for (const k of ak) {
269
+ const av = a![k]
270
+ const bv = b![k]
271
+ if (!attrEqual(av, bv)) {
272
+ return {
273
+ equal: false,
274
+ path: `${path}.attrs.${k}`,
275
+ reason: `attr value: ${JSON.stringify(av)} vs ${JSON.stringify(bv)}`,
276
+ }
277
+ }
278
+ }
279
+ return { equal: true, path: '', reason: '' }
280
+ }
281
+
282
+ function attrEqual(a: JsonAttr, b: JsonAttr): boolean {
283
+ if (a === b) return true
284
+ if (a === null || b === null) return false
285
+ if (typeof a !== typeof b) return false
286
+ if (Array.isArray(a) && Array.isArray(b)) {
287
+ if (a.length !== b.length) return false
288
+ return a.every((v, i) => attrEqual(v, b[i]!))
289
+ }
290
+ if (typeof a === 'object' && typeof b === 'object') {
291
+ const ak = Object.keys(a).sort()
292
+ const bk = Object.keys(b).sort()
293
+ if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false
294
+ return ak.every(k => attrEqual((a as Record<string, JsonAttr>)[k]!, (b as Record<string, JsonAttr>)[k]!))
295
+ }
296
+ return false
297
+ }
298
+
299
+ /** Stringify a YjsJsonNode with sorted keys for byte-stable golden files. */
300
+ export function stringifyJsonNode(node: YjsJsonNode): string {
301
+ return JSON.stringify(node, null, 2) + '\n'
302
+ }