@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.
- package/dist/abracadabra-convert.cjs +3237 -0
- package/dist/abracadabra-convert.cjs.map +1 -0
- package/dist/abracadabra-convert.esm.js +3163 -0
- package/dist/abracadabra-convert.esm.js.map +1 -0
- package/dist/index.d.ts +356 -0
- package/package.json +41 -0
- package/src/diff.ts +302 -0
- package/src/file-blocks/manifest.ts +169 -0
- package/src/file-blocks/paths.ts +207 -0
- package/src/html-to-yjs.ts +322 -0
- package/src/index.ts +103 -0
- package/src/markdown-to-yjs.ts +1208 -0
- package/src/spec/index.ts +7 -0
- package/src/spec/marks.ts +92 -0
- package/src/spec/nodes.ts +333 -0
- package/src/spec/universal-meta.ts +147 -0
- package/src/types.ts +89 -0
- package/src/yjs-to-markdown.ts +820 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|