@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
|
@@ -0,0 +1,3163 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
//#region packages/convert/src/markdown-to-yjs.ts
|
|
4
|
+
/**
|
|
5
|
+
* Converts a filename (without extension) to a human-readable label.
|
|
6
|
+
*
|
|
7
|
+
* - `this-is-a-doc` → `"This is a doc"` (kebab/snake: sentence case)
|
|
8
|
+
* - `ThisIsADoc` → `"This Is A Doc"` (PascalCase: preserves word caps)
|
|
9
|
+
* - `thisIsADoc` → `"This Is A Doc"` (camelCase: preserves word caps)
|
|
10
|
+
*/
|
|
11
|
+
function filenameToLabel(raw) {
|
|
12
|
+
const clean = raw.replace(/\.[^.]+$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[-_.]+/g, " ").replace(/\s+/g, " ").trim();
|
|
13
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
14
|
+
}
|
|
15
|
+
function parseInlineArray(raw) {
|
|
16
|
+
return raw.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
function stripQuotes(s) {
|
|
19
|
+
if (s.length >= 2 && (s.startsWith("\"") && s.endsWith("\"") || s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
function parseFrontmatter(markdown) {
|
|
23
|
+
const noResult = {
|
|
24
|
+
meta: {},
|
|
25
|
+
body: markdown
|
|
26
|
+
};
|
|
27
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
28
|
+
if (!match) return noResult;
|
|
29
|
+
const yamlBlock = match[1];
|
|
30
|
+
const body = markdown.slice(match[0].length);
|
|
31
|
+
const raw = {};
|
|
32
|
+
const lines = yamlBlock.split("\n");
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
const blockSeqKey = line.match(/^(\w[\w-]*):\s*$/);
|
|
37
|
+
if (blockSeqKey && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
|
|
38
|
+
const key = blockSeqKey[1];
|
|
39
|
+
const items = [];
|
|
40
|
+
i++;
|
|
41
|
+
while (i < lines.length && /^\s+-\s/.test(lines[i])) {
|
|
42
|
+
items.push(lines[i].replace(/^\s+-\s/, "").trim());
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
raw[key] = items;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
49
|
+
if (kvMatch) {
|
|
50
|
+
const key = kvMatch[1];
|
|
51
|
+
const val = kvMatch[2].trim();
|
|
52
|
+
if (val.startsWith("[") && val.endsWith("]")) raw[key] = parseInlineArray(val).map(stripQuotes);
|
|
53
|
+
else raw[key] = stripQuotes(val);
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
const meta = {};
|
|
58
|
+
const getStr = (keys) => {
|
|
59
|
+
for (const k of keys) {
|
|
60
|
+
const v = raw[k];
|
|
61
|
+
if (typeof v === "string" && v) return v;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (raw["tags"]) meta.tags = Array.isArray(raw["tags"]) ? raw["tags"] : [raw["tags"]];
|
|
65
|
+
const color = getStr(["color"]);
|
|
66
|
+
if (color) meta.color = color;
|
|
67
|
+
const icon = getStr(["icon"]);
|
|
68
|
+
if (icon) meta.icon = icon;
|
|
69
|
+
const status = getStr(["status"]);
|
|
70
|
+
if (status) meta.status = status;
|
|
71
|
+
const priorityRaw = getStr(["priority"]);
|
|
72
|
+
if (priorityRaw !== void 0) meta.priority = {
|
|
73
|
+
low: 1,
|
|
74
|
+
medium: 2,
|
|
75
|
+
high: 3,
|
|
76
|
+
urgent: 4
|
|
77
|
+
}[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0);
|
|
78
|
+
const checkedRaw = raw["checked"] ?? raw["done"];
|
|
79
|
+
if (checkedRaw !== void 0) meta.checked = checkedRaw === "true" || checkedRaw === true;
|
|
80
|
+
const dateStart = getStr([
|
|
81
|
+
"dateStart",
|
|
82
|
+
"date",
|
|
83
|
+
"created"
|
|
84
|
+
]);
|
|
85
|
+
if (dateStart) meta.dateStart = dateStart;
|
|
86
|
+
const dateEnd = getStr(["dateEnd", "due"]);
|
|
87
|
+
if (dateEnd) meta.dateEnd = dateEnd;
|
|
88
|
+
const subtitle = getStr(["subtitle", "description"]);
|
|
89
|
+
if (subtitle) meta.subtitle = subtitle;
|
|
90
|
+
const url = getStr(["url"]);
|
|
91
|
+
if (url) meta.url = url;
|
|
92
|
+
const ratingRaw = getStr(["rating"]);
|
|
93
|
+
if (ratingRaw !== void 0) {
|
|
94
|
+
const n = Number(ratingRaw);
|
|
95
|
+
if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n));
|
|
96
|
+
}
|
|
97
|
+
const rawTitle = typeof raw["title"] === "string" ? raw["title"] : void 0;
|
|
98
|
+
return {
|
|
99
|
+
title: rawTitle !== void 0 ? stripQuotes(rawTitle) : void 0,
|
|
100
|
+
type: getStr(["type"]),
|
|
101
|
+
meta,
|
|
102
|
+
body
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseInline(text) {
|
|
106
|
+
const stripped = text.replace(/\{lang="[^"]*"\}/g, "").replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, "$2").replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, "");
|
|
107
|
+
const tokens = [];
|
|
108
|
+
const re = /\$([^$\n]+?)\$|@\[([^\]]+?)\]\(user:([^)]+?)\)|:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g;
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
let match;
|
|
111
|
+
while ((match = re.exec(stripped)) !== null) {
|
|
112
|
+
if (match.index > lastIndex) tokens.push({ text: stripped.slice(lastIndex, match.index) });
|
|
113
|
+
if (match[1] !== void 0) tokens.push({
|
|
114
|
+
text: match[1],
|
|
115
|
+
attrs: { mathInline: { expression: match[1] } }
|
|
116
|
+
});
|
|
117
|
+
else if (match[2] !== void 0 && match[3] !== void 0) tokens.push({
|
|
118
|
+
text: match[2],
|
|
119
|
+
attrs: { mention: {
|
|
120
|
+
userId: match[3],
|
|
121
|
+
label: match[2]
|
|
122
|
+
} }
|
|
123
|
+
});
|
|
124
|
+
else if (match[4] !== void 0) {
|
|
125
|
+
const badgeProps = parseMdcProps(match[5]);
|
|
126
|
+
tokens.push({
|
|
127
|
+
text: match[4] || "Badge",
|
|
128
|
+
attrs: { badge: {
|
|
129
|
+
label: match[4] || "Badge",
|
|
130
|
+
color: badgeProps["color"] || "neutral",
|
|
131
|
+
variant: badgeProps["variant"] || "subtle"
|
|
132
|
+
} }
|
|
133
|
+
});
|
|
134
|
+
} else if (match[6] !== void 0) {
|
|
135
|
+
const iconProps = parseMdcProps(`{${match[6]}}`);
|
|
136
|
+
tokens.push({
|
|
137
|
+
text: "",
|
|
138
|
+
attrs: { proseIcon: { name: iconProps["name"] || "i-lucide-star" } }
|
|
139
|
+
});
|
|
140
|
+
} else if (match[7] !== void 0) {
|
|
141
|
+
const kbdProps = parseMdcProps(`{${match[7]}}`);
|
|
142
|
+
tokens.push({
|
|
143
|
+
text: kbdProps["value"] || "",
|
|
144
|
+
attrs: { kbd: { value: kbdProps["value"] || "" } }
|
|
145
|
+
});
|
|
146
|
+
} else if (match[8] !== void 0) {
|
|
147
|
+
const docId = match[8];
|
|
148
|
+
const label = match[9] ?? docId;
|
|
149
|
+
tokens.push({
|
|
150
|
+
text: label,
|
|
151
|
+
attrs: { docLink: { docId } }
|
|
152
|
+
});
|
|
153
|
+
} else if (match[10] !== void 0) tokens.push({
|
|
154
|
+
text: match[10],
|
|
155
|
+
attrs: { strike: true }
|
|
156
|
+
});
|
|
157
|
+
else if (match[11] !== void 0) tokens.push({
|
|
158
|
+
text: match[11],
|
|
159
|
+
attrs: { bold: true }
|
|
160
|
+
});
|
|
161
|
+
else if (match[12] !== void 0) tokens.push({
|
|
162
|
+
text: match[12],
|
|
163
|
+
attrs: { italic: true }
|
|
164
|
+
});
|
|
165
|
+
else if (match[13] !== void 0) tokens.push({
|
|
166
|
+
text: match[13],
|
|
167
|
+
attrs: { italic: true }
|
|
168
|
+
});
|
|
169
|
+
else if (match[14] !== void 0) tokens.push({
|
|
170
|
+
text: match[14],
|
|
171
|
+
attrs: { code: true }
|
|
172
|
+
});
|
|
173
|
+
else if (match[15] !== void 0 && match[16] !== void 0) tokens.push({
|
|
174
|
+
text: match[15],
|
|
175
|
+
attrs: { link: { href: match[16] } }
|
|
176
|
+
});
|
|
177
|
+
lastIndex = match.index + match[0].length;
|
|
178
|
+
}
|
|
179
|
+
if (lastIndex < stripped.length) tokens.push({ text: stripped.slice(lastIndex) });
|
|
180
|
+
return tokens.filter((t) => t.text.length > 0);
|
|
181
|
+
}
|
|
182
|
+
function parseTableRow(line) {
|
|
183
|
+
const parts = line.split("|");
|
|
184
|
+
return parts.slice(1, parts.length - 1).map((c) => c.trim());
|
|
185
|
+
}
|
|
186
|
+
function isTableSeparator(line) {
|
|
187
|
+
return /^\|[\s|:-]+\|$/.test(line.trim());
|
|
188
|
+
}
|
|
189
|
+
/** Extract fenced code blocks from MDC #code slot lines. */
|
|
190
|
+
function extractFencedCode(lines) {
|
|
191
|
+
const result = [];
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (i < lines.length) {
|
|
194
|
+
const fenceMatch = lines[i].match(/^(`{3,})(\w*)/);
|
|
195
|
+
if (fenceMatch) {
|
|
196
|
+
const fence = fenceMatch[1];
|
|
197
|
+
const lang = fenceMatch[2] ?? "";
|
|
198
|
+
const codeLines = [];
|
|
199
|
+
i++;
|
|
200
|
+
while (i < lines.length && !lines[i].startsWith(fence)) {
|
|
201
|
+
codeLines.push(lines[i]);
|
|
202
|
+
i++;
|
|
203
|
+
}
|
|
204
|
+
i++;
|
|
205
|
+
result.push({
|
|
206
|
+
type: "codeBlock",
|
|
207
|
+
lang,
|
|
208
|
+
code: codeLines.join("\n")
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
/** Extract key="value" pairs from MDC prop syntax `{key="value" other="x"}` */
|
|
217
|
+
function parseMdcProps(propsStr) {
|
|
218
|
+
if (!propsStr) return {};
|
|
219
|
+
const result = {};
|
|
220
|
+
let s = propsStr.trim();
|
|
221
|
+
if (s.startsWith("{") && s.endsWith("}")) s = s.slice(1, -1);
|
|
222
|
+
const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|([^\s"}]+)))?/g;
|
|
223
|
+
let m;
|
|
224
|
+
while ((m = re.exec(s)) !== null) {
|
|
225
|
+
const key = m[1];
|
|
226
|
+
if (m[2] !== void 0) result[key] = m[2];
|
|
227
|
+
else if (m[3] !== void 0) result[key] = m[3];
|
|
228
|
+
else result[key] = "true";
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
/** Parse named child MDC blocks from inner lines (e.g. #item for accordion, #tab for tabs) */
|
|
233
|
+
function parseMdcChildren(innerLines, slotPrefix) {
|
|
234
|
+
const items = [];
|
|
235
|
+
let current = null;
|
|
236
|
+
const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`);
|
|
237
|
+
for (const line of innerLines) {
|
|
238
|
+
const slotMatch = line.match(slotRe);
|
|
239
|
+
if (slotMatch) {
|
|
240
|
+
if (current) items.push(current);
|
|
241
|
+
const props = parseMdcProps(slotMatch[1]);
|
|
242
|
+
current = {
|
|
243
|
+
label: props["label"] || props["title"] || `Item ${items.length + 1}`,
|
|
244
|
+
icon: props["icon"] || "",
|
|
245
|
+
lines: []
|
|
246
|
+
};
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (current) current.lines.push(line);
|
|
250
|
+
else if (!items.length && !current) current = {
|
|
251
|
+
label: `Item 1`,
|
|
252
|
+
icon: "",
|
|
253
|
+
lines: [line]
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (current) items.push(current);
|
|
257
|
+
return items.map((item) => ({
|
|
258
|
+
label: item.label,
|
|
259
|
+
icon: item.icon,
|
|
260
|
+
innerBlocks: parseBlocks(item.lines.join("\n"))
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/;
|
|
264
|
+
/**
|
|
265
|
+
* Consume a list (bullet / ordered / task) starting at `start`. Indented
|
|
266
|
+
* continuation lines and nested lists are captured into each item's
|
|
267
|
+
* `innerBlocks` so the parse → serialise → parse cycle preserves tree
|
|
268
|
+
* structure instead of flattening nested lists onto a single line.
|
|
269
|
+
*
|
|
270
|
+
* `indent` is the column of the item marker for the current list. A
|
|
271
|
+
* nested list starts ≥2 columns deeper. Lines with less indent than
|
|
272
|
+
* `indent` belong to the outer block and stop consumption.
|
|
273
|
+
*/
|
|
274
|
+
function consumeList(lines, start, indent, kind) {
|
|
275
|
+
const items = [];
|
|
276
|
+
let i = start;
|
|
277
|
+
while (i < lines.length) {
|
|
278
|
+
const line = lines[i];
|
|
279
|
+
if (line.trim() === "") {
|
|
280
|
+
let j = i + 1;
|
|
281
|
+
while (j < lines.length && lines[j].trim() === "") j++;
|
|
282
|
+
if (j >= lines.length) break;
|
|
283
|
+
const lookahead = lines[j];
|
|
284
|
+
if (leadingSpaces(lookahead) < indent) break;
|
|
285
|
+
if (!matchMarker(lookahead.slice(indent), kind)) break;
|
|
286
|
+
i = j;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const leading = leadingSpaces(line);
|
|
290
|
+
if (leading < indent) break;
|
|
291
|
+
if (leading > indent) break;
|
|
292
|
+
const m = matchMarker(line.slice(indent), kind);
|
|
293
|
+
if (!m) break;
|
|
294
|
+
const item = { text: m.text };
|
|
295
|
+
if (kind === "task") item.checked = m.checked;
|
|
296
|
+
i++;
|
|
297
|
+
const contLines = [];
|
|
298
|
+
while (i < lines.length) {
|
|
299
|
+
const next = lines[i];
|
|
300
|
+
if (next.trim() === "") {
|
|
301
|
+
let k = i + 1;
|
|
302
|
+
while (k < lines.length && lines[k].trim() === "") k++;
|
|
303
|
+
if (k >= lines.length) break;
|
|
304
|
+
if (leadingSpaces(lines[k]) <= indent) break;
|
|
305
|
+
contLines.push("");
|
|
306
|
+
i++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const nextIndent = leadingSpaces(next);
|
|
310
|
+
if (nextIndent <= indent) break;
|
|
311
|
+
const deindentBy = Math.min(nextIndent, indent + 2);
|
|
312
|
+
contLines.push(next.slice(deindentBy));
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
if (contLines.length > 0) item.innerBlocks = parseBlocks(contLines.join("\n"));
|
|
316
|
+
items.push(item);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
items,
|
|
320
|
+
next: i
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function leadingSpaces(s) {
|
|
324
|
+
let n = 0;
|
|
325
|
+
while (n < s.length && s[n] === " ") n++;
|
|
326
|
+
return n;
|
|
327
|
+
}
|
|
328
|
+
function matchMarker(s, kind) {
|
|
329
|
+
if (kind === "task") {
|
|
330
|
+
const m = s.match(TASK_RE);
|
|
331
|
+
if (!m) return null;
|
|
332
|
+
return {
|
|
333
|
+
text: m[2],
|
|
334
|
+
checked: m[1].toLowerCase() === "x"
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (kind === "bullet") {
|
|
338
|
+
if (TASK_RE.test(s)) return null;
|
|
339
|
+
const m = s.match(/^[-*+]\s+(.*)$/);
|
|
340
|
+
if (!m) return null;
|
|
341
|
+
return {
|
|
342
|
+
text: m[1],
|
|
343
|
+
checked: false
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const m = s.match(/^\d+\.\s+(.*)$/);
|
|
347
|
+
if (!m) return null;
|
|
348
|
+
return {
|
|
349
|
+
text: m[1],
|
|
350
|
+
checked: false
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function parseBlocks(markdown) {
|
|
354
|
+
const rawLines = markdown.split("\n");
|
|
355
|
+
let firstContentLine = 0;
|
|
356
|
+
while (firstContentLine < rawLines.length) {
|
|
357
|
+
const l = rawLines[firstContentLine];
|
|
358
|
+
if (l.trim() === "" || /^import\s/.test(l) || /^export\s/.test(l)) firstContentLine++;
|
|
359
|
+
else break;
|
|
360
|
+
}
|
|
361
|
+
const stripped = rawLines.slice(firstContentLine).join("\n");
|
|
362
|
+
const blocks = [];
|
|
363
|
+
const lines = stripped.split("\n");
|
|
364
|
+
let i = 0;
|
|
365
|
+
while (i < lines.length) {
|
|
366
|
+
const line = lines[i];
|
|
367
|
+
const fenceBlockMatch = line.match(/^(`{3,})(.*)$/);
|
|
368
|
+
if (fenceBlockMatch) {
|
|
369
|
+
const fence = fenceBlockMatch[1];
|
|
370
|
+
const lang = fenceBlockMatch[2].trim().replace(/\{[^}]*\}$/, "").replace(/\s*\[.*\]$/, "").trim();
|
|
371
|
+
const codeLines = [];
|
|
372
|
+
i++;
|
|
373
|
+
while (i < lines.length && !lines[i].startsWith(fence)) {
|
|
374
|
+
codeLines.push(lines[i]);
|
|
375
|
+
i++;
|
|
376
|
+
}
|
|
377
|
+
i++;
|
|
378
|
+
const code = codeLines.join("\n");
|
|
379
|
+
if (lang === "math") blocks.push({
|
|
380
|
+
type: "mathBlock",
|
|
381
|
+
expression: code
|
|
382
|
+
});
|
|
383
|
+
else blocks.push({
|
|
384
|
+
type: "codeBlock",
|
|
385
|
+
lang,
|
|
386
|
+
code
|
|
387
|
+
});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
391
|
+
if (headingMatch) {
|
|
392
|
+
blocks.push({
|
|
393
|
+
type: "heading",
|
|
394
|
+
level: headingMatch[1].length,
|
|
395
|
+
text: headingMatch[2].trim()
|
|
396
|
+
});
|
|
397
|
+
i++;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
401
|
+
blocks.push({ type: "hr" });
|
|
402
|
+
i++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const embedMatch = line.match(/^!\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+))?\]\](\{[^}]*\})?\s*$/);
|
|
406
|
+
if (embedMatch) {
|
|
407
|
+
const docId = embedMatch[1];
|
|
408
|
+
const label = embedMatch[2] ?? "";
|
|
409
|
+
const props = parseMdcProps(embedMatch[3]);
|
|
410
|
+
blocks.push({
|
|
411
|
+
type: "docEmbed",
|
|
412
|
+
docId,
|
|
413
|
+
label,
|
|
414
|
+
props
|
|
415
|
+
});
|
|
416
|
+
i++;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/);
|
|
420
|
+
if (imgMatch) {
|
|
421
|
+
const alt = imgMatch[1] ?? "";
|
|
422
|
+
const src = imgMatch[2] ?? "";
|
|
423
|
+
const attrs = parseMdcProps(imgMatch[3]);
|
|
424
|
+
blocks.push({
|
|
425
|
+
type: "image",
|
|
426
|
+
src,
|
|
427
|
+
alt,
|
|
428
|
+
width: attrs["width"],
|
|
429
|
+
height: attrs["height"]
|
|
430
|
+
});
|
|
431
|
+
i++;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (line.startsWith("> ") || line === ">") {
|
|
435
|
+
const bqLines = [];
|
|
436
|
+
while (i < lines.length && (lines[i].startsWith("> ") || lines[i] === ">")) {
|
|
437
|
+
bqLines.push(lines[i].replace(/^>\s?/, ""));
|
|
438
|
+
i++;
|
|
439
|
+
}
|
|
440
|
+
blocks.push({
|
|
441
|
+
type: "blockquote",
|
|
442
|
+
lines: bqLines
|
|
443
|
+
});
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (/^\s*\|/.test(line)) {
|
|
447
|
+
const tableLines = [];
|
|
448
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) {
|
|
449
|
+
tableLines.push(lines[i]);
|
|
450
|
+
i++;
|
|
451
|
+
}
|
|
452
|
+
if (tableLines.length >= 2 && isTableSeparator(tableLines[1])) {
|
|
453
|
+
const headerRow = parseTableRow(tableLines[0]);
|
|
454
|
+
const dataRows = tableLines.slice(2).filter((l) => !isTableSeparator(l)).map(parseTableRow);
|
|
455
|
+
blocks.push({
|
|
456
|
+
type: "table",
|
|
457
|
+
headerRow,
|
|
458
|
+
dataRows
|
|
459
|
+
});
|
|
460
|
+
} else for (const l of tableLines) blocks.push({
|
|
461
|
+
type: "paragraph",
|
|
462
|
+
text: l
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const atomMatch = line.match(/^:(\w[\w-]*)(\{[^}]*\})?\s*$/);
|
|
467
|
+
if (atomMatch && atomMatch[1] === "file") {
|
|
468
|
+
const props = parseMdcProps(atomMatch[2]);
|
|
469
|
+
const uploadId = props["upload-id"] ?? props["uploadId"] ?? "";
|
|
470
|
+
const filename = props["filename"] ?? "";
|
|
471
|
+
const mime = props["mime"] ?? "";
|
|
472
|
+
const src = props["src"] ?? (uploadId && filename ? `.abracadabra/files/${uploadId}-${filename}` : "");
|
|
473
|
+
blocks.push({
|
|
474
|
+
type: "fileBlock",
|
|
475
|
+
src,
|
|
476
|
+
mime,
|
|
477
|
+
uploadId,
|
|
478
|
+
filename
|
|
479
|
+
});
|
|
480
|
+
i++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/;
|
|
484
|
+
if (MDC_OPEN.test(line)) {
|
|
485
|
+
const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2;
|
|
486
|
+
const componentName = line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? "";
|
|
487
|
+
const innerLines = [];
|
|
488
|
+
i++;
|
|
489
|
+
while (i < lines.length) {
|
|
490
|
+
const l = lines[i];
|
|
491
|
+
if (new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)) {
|
|
492
|
+
i++;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
const innerFence = l.match(/^(\s*`{3,})/);
|
|
496
|
+
if (innerFence) {
|
|
497
|
+
const fenceStr = innerFence[1].trimStart();
|
|
498
|
+
innerLines.push(l);
|
|
499
|
+
i++;
|
|
500
|
+
while (i < lines.length && !lines[i].trimStart().startsWith(fenceStr)) {
|
|
501
|
+
innerLines.push(lines[i]);
|
|
502
|
+
i++;
|
|
503
|
+
}
|
|
504
|
+
if (i < lines.length) {
|
|
505
|
+
innerLines.push(lines[i]);
|
|
506
|
+
i++;
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
innerLines.push(l);
|
|
511
|
+
i++;
|
|
512
|
+
}
|
|
513
|
+
const nonBlank = innerLines.filter((l) => l.trim().length > 0);
|
|
514
|
+
if (nonBlank.length) {
|
|
515
|
+
const minIndent = Math.min(...nonBlank.map((l) => l.match(/^(\s*)/)?.[1]?.length ?? 0));
|
|
516
|
+
if (minIndent > 0) for (let j = 0; j < innerLines.length; j++) innerLines[j] = innerLines[j].slice(Math.min(minIndent, innerLines[j].length));
|
|
517
|
+
}
|
|
518
|
+
let contentStart = 0;
|
|
519
|
+
if (innerLines[0]?.trim() === "---") {
|
|
520
|
+
const fmEnd = innerLines.findIndex((l, idx) => idx > 0 && l.trim() === "---");
|
|
521
|
+
if (fmEnd !== -1) contentStart = fmEnd + 1;
|
|
522
|
+
}
|
|
523
|
+
const contentLines = innerLines.slice(contentStart);
|
|
524
|
+
const defaultSlotLines = [];
|
|
525
|
+
const codeSlotLines = [];
|
|
526
|
+
let currentSlot = "default";
|
|
527
|
+
for (const l of contentLines) {
|
|
528
|
+
if (/^#code\s*$/.test(l)) {
|
|
529
|
+
currentSlot = "code";
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) {
|
|
533
|
+
currentSlot = "other";
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (currentSlot === "default") defaultSlotLines.push(l);
|
|
537
|
+
else if (currentSlot === "code") codeSlotLines.push(l);
|
|
538
|
+
}
|
|
539
|
+
const innerBlocks = parseBlocks(defaultSlotLines.join("\n"));
|
|
540
|
+
const codeBlocks = extractFencedCode(codeSlotLines);
|
|
541
|
+
if (new Set([
|
|
542
|
+
"tip",
|
|
543
|
+
"note",
|
|
544
|
+
"info",
|
|
545
|
+
"warning",
|
|
546
|
+
"caution",
|
|
547
|
+
"danger",
|
|
548
|
+
"callout",
|
|
549
|
+
"alert"
|
|
550
|
+
]).has(componentName.toLowerCase())) blocks.push({
|
|
551
|
+
type: "callout",
|
|
552
|
+
calloutType: componentName.toLowerCase(),
|
|
553
|
+
innerBlocks
|
|
554
|
+
});
|
|
555
|
+
else {
|
|
556
|
+
const mdcProps = parseMdcProps(line.match(MDC_OPEN)?.[3]);
|
|
557
|
+
const lc = componentName.toLowerCase();
|
|
558
|
+
if (lc === "collapsible") blocks.push({
|
|
559
|
+
type: "collapsible",
|
|
560
|
+
label: mdcProps["label"] || "Details",
|
|
561
|
+
open: mdcProps["open"] === "true",
|
|
562
|
+
innerBlocks
|
|
563
|
+
});
|
|
564
|
+
else if (lc === "steps") blocks.push({
|
|
565
|
+
type: "steps",
|
|
566
|
+
innerBlocks
|
|
567
|
+
});
|
|
568
|
+
else if (lc === "card") blocks.push({
|
|
569
|
+
type: "card",
|
|
570
|
+
title: mdcProps["title"] || "",
|
|
571
|
+
icon: mdcProps["icon"] || "",
|
|
572
|
+
to: mdcProps["to"] || "",
|
|
573
|
+
innerBlocks
|
|
574
|
+
});
|
|
575
|
+
else if (lc === "card-group") {
|
|
576
|
+
const cards = innerBlocks.filter((b) => b.type === "card");
|
|
577
|
+
if (cards.length) blocks.push({
|
|
578
|
+
type: "cardGroup",
|
|
579
|
+
cards
|
|
580
|
+
});
|
|
581
|
+
else blocks.push(...innerBlocks);
|
|
582
|
+
} else if (lc === "code-collapse") blocks.push({
|
|
583
|
+
type: "codeCollapse",
|
|
584
|
+
codeBlocks: codeBlocks.length ? codeBlocks : innerBlocks.filter((b) => b.type === "codeBlock")
|
|
585
|
+
});
|
|
586
|
+
else if (lc === "code-group") {
|
|
587
|
+
const allCode = [...innerBlocks.filter((b) => b.type === "codeBlock"), ...codeBlocks];
|
|
588
|
+
blocks.push({
|
|
589
|
+
type: "codeGroup",
|
|
590
|
+
codeBlocks: allCode
|
|
591
|
+
});
|
|
592
|
+
} else if (lc === "code-preview") blocks.push({
|
|
593
|
+
type: "codePreview",
|
|
594
|
+
innerBlocks,
|
|
595
|
+
codeBlocks
|
|
596
|
+
});
|
|
597
|
+
else if (lc === "code-tree") blocks.push({
|
|
598
|
+
type: "codeTree",
|
|
599
|
+
files: mdcProps["files"] || "[]"
|
|
600
|
+
});
|
|
601
|
+
else if (lc === "accordion") {
|
|
602
|
+
const items = parseMdcChildren(contentLines, "item");
|
|
603
|
+
if (items.length) blocks.push({
|
|
604
|
+
type: "accordion",
|
|
605
|
+
items
|
|
606
|
+
});
|
|
607
|
+
else blocks.push({
|
|
608
|
+
type: "accordion",
|
|
609
|
+
items: [{
|
|
610
|
+
label: "Item 1",
|
|
611
|
+
icon: "",
|
|
612
|
+
innerBlocks
|
|
613
|
+
}]
|
|
614
|
+
});
|
|
615
|
+
} else if (lc === "tabs") {
|
|
616
|
+
const items = parseMdcChildren(contentLines, "tab");
|
|
617
|
+
if (items.length) blocks.push({
|
|
618
|
+
type: "tabs",
|
|
619
|
+
items
|
|
620
|
+
});
|
|
621
|
+
else blocks.push({
|
|
622
|
+
type: "tabs",
|
|
623
|
+
items: [{
|
|
624
|
+
label: "Tab 1",
|
|
625
|
+
icon: "",
|
|
626
|
+
innerBlocks
|
|
627
|
+
}]
|
|
628
|
+
});
|
|
629
|
+
} else if (lc === "field") blocks.push({
|
|
630
|
+
type: "field",
|
|
631
|
+
name: mdcProps["name"] || "",
|
|
632
|
+
fieldType: mdcProps["type"] || "string",
|
|
633
|
+
required: mdcProps["required"] === "true",
|
|
634
|
+
innerBlocks
|
|
635
|
+
});
|
|
636
|
+
else if (lc === "field-group") {
|
|
637
|
+
const fields = innerBlocks.filter((b) => b.type === "field");
|
|
638
|
+
if (fields.length) blocks.push({
|
|
639
|
+
type: "fieldGroup",
|
|
640
|
+
fields
|
|
641
|
+
});
|
|
642
|
+
else blocks.push(...innerBlocks);
|
|
643
|
+
} else {
|
|
644
|
+
blocks.push(...innerBlocks);
|
|
645
|
+
blocks.push(...codeBlocks);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (TASK_RE.test(line)) {
|
|
651
|
+
const { items, next } = consumeList(lines, i, 0, "task");
|
|
652
|
+
i = next;
|
|
653
|
+
blocks.push({
|
|
654
|
+
type: "taskList",
|
|
655
|
+
items
|
|
656
|
+
});
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (/^[-*+]\s+/.test(line)) {
|
|
660
|
+
const { items, next } = consumeList(lines, i, 0, "bullet");
|
|
661
|
+
if (items.length > 0) {
|
|
662
|
+
i = next;
|
|
663
|
+
blocks.push({
|
|
664
|
+
type: "bulletList",
|
|
665
|
+
items
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
671
|
+
const { items, next } = consumeList(lines, i, 0, "ordered");
|
|
672
|
+
if (items.length > 0) {
|
|
673
|
+
i = next;
|
|
674
|
+
blocks.push({
|
|
675
|
+
type: "orderedList",
|
|
676
|
+
items
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (line.trim() === "") {
|
|
682
|
+
i++;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const paraLines = [];
|
|
686
|
+
while (i < lines.length && lines[i].trim() !== "" && !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(lines[i])) {
|
|
687
|
+
paraLines.push(lines[i]);
|
|
688
|
+
i++;
|
|
689
|
+
}
|
|
690
|
+
if (paraLines.length) blocks.push({
|
|
691
|
+
type: "paragraph",
|
|
692
|
+
text: paraLines.join(" ")
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return blocks;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Insert formatted inline tokens into an already-attached Y.XmlElement.
|
|
699
|
+
* Creates one Y.XmlText per token (attach first, fill second).
|
|
700
|
+
*/
|
|
701
|
+
function fillTextInto(el, tokens) {
|
|
702
|
+
const filtered = tokens.filter((t) => t.text.length > 0);
|
|
703
|
+
if (!filtered.length) return;
|
|
704
|
+
const xtNodes = filtered.map(() => new Y.XmlText());
|
|
705
|
+
el.insert(0, xtNodes);
|
|
706
|
+
filtered.forEach((tok, i) => {
|
|
707
|
+
if (tok.attrs) xtNodes[i].insert(0, tok.text, tok.attrs);
|
|
708
|
+
else xtNodes[i].insert(0, tok.text);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
function blockElName(b) {
|
|
712
|
+
switch (b.type) {
|
|
713
|
+
case "heading": return "heading";
|
|
714
|
+
case "paragraph": return "paragraph";
|
|
715
|
+
case "bulletList": return "bulletList";
|
|
716
|
+
case "orderedList": return "orderedList";
|
|
717
|
+
case "taskList": return "taskList";
|
|
718
|
+
case "codeBlock": return "codeBlock";
|
|
719
|
+
case "blockquote": return "blockquote";
|
|
720
|
+
case "table": return "table";
|
|
721
|
+
case "hr": return "horizontalRule";
|
|
722
|
+
case "callout": return "callout";
|
|
723
|
+
case "collapsible": return "collapsible";
|
|
724
|
+
case "steps": return "steps";
|
|
725
|
+
case "card": return "card";
|
|
726
|
+
case "cardGroup": return "cardGroup";
|
|
727
|
+
case "codeCollapse": return "codeCollapse";
|
|
728
|
+
case "codeGroup": return "codeGroup";
|
|
729
|
+
case "codePreview": return "codePreview";
|
|
730
|
+
case "codeTree": return "codeTree";
|
|
731
|
+
case "accordion": return "accordion";
|
|
732
|
+
case "tabs": return "tabs";
|
|
733
|
+
case "field": return "field";
|
|
734
|
+
case "fieldGroup": return "fieldGroup";
|
|
735
|
+
case "image": return "image";
|
|
736
|
+
case "docEmbed": return "docEmbed";
|
|
737
|
+
case "mathBlock": return "mathBlock";
|
|
738
|
+
case "fileBlock": return "fileBlock";
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function populateListItemChildren(itemEl, item, _itemKind) {
|
|
742
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
743
|
+
itemEl.insert(itemEl.length, [paraEl]);
|
|
744
|
+
fillTextInto(paraEl, parseInline(item.text));
|
|
745
|
+
if (!item.innerBlocks?.length) return;
|
|
746
|
+
const innerEls = item.innerBlocks.map((b) => new Y.XmlElement(blockElName(b)));
|
|
747
|
+
itemEl.insert(itemEl.length, innerEls);
|
|
748
|
+
item.innerBlocks.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
749
|
+
}
|
|
750
|
+
function fillBlock(el, block) {
|
|
751
|
+
switch (block.type) {
|
|
752
|
+
case "heading":
|
|
753
|
+
el.setAttribute("level", block.level);
|
|
754
|
+
fillTextInto(el, parseInline(block.text));
|
|
755
|
+
break;
|
|
756
|
+
case "paragraph":
|
|
757
|
+
fillTextInto(el, parseInline(block.text));
|
|
758
|
+
break;
|
|
759
|
+
case "bulletList":
|
|
760
|
+
case "orderedList": {
|
|
761
|
+
const listItemEls = block.items.map(() => new Y.XmlElement("listItem"));
|
|
762
|
+
el.insert(0, listItemEls);
|
|
763
|
+
block.items.forEach((item, i) => {
|
|
764
|
+
populateListItemChildren(listItemEls[i], item, "listItem");
|
|
765
|
+
});
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
case "taskList": {
|
|
769
|
+
const taskItemEls = block.items.map(() => new Y.XmlElement("taskItem"));
|
|
770
|
+
el.insert(0, taskItemEls);
|
|
771
|
+
block.items.forEach((item, i) => {
|
|
772
|
+
taskItemEls[i].setAttribute("checked", !!item.checked);
|
|
773
|
+
populateListItemChildren(taskItemEls[i], item, "taskItem");
|
|
774
|
+
});
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
case "codeBlock": {
|
|
778
|
+
if (block.lang) el.setAttribute("language", block.lang);
|
|
779
|
+
const xt = new Y.XmlText();
|
|
780
|
+
el.insert(0, [xt]);
|
|
781
|
+
xt.insert(0, block.code);
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
case "blockquote": {
|
|
785
|
+
const paraEls = block.lines.map(() => new Y.XmlElement("paragraph"));
|
|
786
|
+
el.insert(0, paraEls);
|
|
787
|
+
block.lines.forEach((line, i) => fillTextInto(paraEls[i], parseInline(line)));
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case "table": {
|
|
791
|
+
const headerRowEl = new Y.XmlElement("tableRow");
|
|
792
|
+
const dataRowEls = block.dataRows.map(() => new Y.XmlElement("tableRow"));
|
|
793
|
+
el.insert(0, [headerRowEl, ...dataRowEls]);
|
|
794
|
+
const headerCellEls = block.headerRow.map(() => new Y.XmlElement("tableHeader"));
|
|
795
|
+
headerRowEl.insert(0, headerCellEls);
|
|
796
|
+
block.headerRow.forEach((cellText, i) => {
|
|
797
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
798
|
+
headerCellEls[i].insert(0, [paraEl]);
|
|
799
|
+
fillTextInto(paraEl, parseInline(cellText));
|
|
800
|
+
});
|
|
801
|
+
block.dataRows.forEach((row, ri) => {
|
|
802
|
+
const cellEls = row.map(() => new Y.XmlElement("tableCell"));
|
|
803
|
+
dataRowEls[ri].insert(0, cellEls);
|
|
804
|
+
row.forEach((cellText, ci) => {
|
|
805
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
806
|
+
cellEls[ci].insert(0, [paraEl]);
|
|
807
|
+
fillTextInto(paraEl, parseInline(cellText));
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
case "hr": break;
|
|
813
|
+
case "callout": {
|
|
814
|
+
el.setAttribute("type", block.calloutType);
|
|
815
|
+
if (!block.innerBlocks.length) {
|
|
816
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
817
|
+
el.insert(0, [paraEl]);
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
const innerEls = block.innerBlocks.map((b) => new Y.XmlElement(blockElName(b)));
|
|
821
|
+
el.insert(0, innerEls);
|
|
822
|
+
block.innerBlocks.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case "collapsible": {
|
|
826
|
+
el.setAttribute("label", block.label);
|
|
827
|
+
el.setAttribute("open", block.open);
|
|
828
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{
|
|
829
|
+
type: "paragraph",
|
|
830
|
+
text: ""
|
|
831
|
+
}];
|
|
832
|
+
const innerEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
833
|
+
el.insert(0, innerEls);
|
|
834
|
+
inner.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
case "steps": {
|
|
838
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{
|
|
839
|
+
type: "paragraph",
|
|
840
|
+
text: ""
|
|
841
|
+
}];
|
|
842
|
+
const innerEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
843
|
+
el.insert(0, innerEls);
|
|
844
|
+
inner.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
case "card": {
|
|
848
|
+
if (block.title) el.setAttribute("title", block.title);
|
|
849
|
+
if (block.icon) el.setAttribute("icon", block.icon);
|
|
850
|
+
if (block.to) el.setAttribute("to", block.to);
|
|
851
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{
|
|
852
|
+
type: "paragraph",
|
|
853
|
+
text: ""
|
|
854
|
+
}];
|
|
855
|
+
const innerEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
856
|
+
el.insert(0, innerEls);
|
|
857
|
+
inner.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "cardGroup": {
|
|
861
|
+
const cardEls = block.cards.map((b) => new Y.XmlElement(blockElName(b)));
|
|
862
|
+
el.insert(0, cardEls);
|
|
863
|
+
block.cards.forEach((b, i) => fillBlock(cardEls[i], b));
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
case "codeCollapse": {
|
|
867
|
+
const codes = block.codeBlocks.length ? block.codeBlocks : [{
|
|
868
|
+
type: "codeBlock",
|
|
869
|
+
lang: "",
|
|
870
|
+
code: ""
|
|
871
|
+
}];
|
|
872
|
+
const codeEl = new Y.XmlElement("codeBlock");
|
|
873
|
+
el.insert(0, [codeEl]);
|
|
874
|
+
fillBlock(codeEl, codes[0]);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
case "codeGroup": {
|
|
878
|
+
const codes = block.codeBlocks.length ? block.codeBlocks : [{
|
|
879
|
+
type: "codeBlock",
|
|
880
|
+
lang: "",
|
|
881
|
+
code: ""
|
|
882
|
+
}];
|
|
883
|
+
const codeEls = codes.map(() => new Y.XmlElement("codeBlock"));
|
|
884
|
+
el.insert(0, codeEls);
|
|
885
|
+
codes.forEach((b, i) => fillBlock(codeEls[i], b));
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case "codePreview": {
|
|
889
|
+
const all = [...block.innerBlocks, ...block.codeBlocks];
|
|
890
|
+
const inner = all.length ? all : [{
|
|
891
|
+
type: "paragraph",
|
|
892
|
+
text: ""
|
|
893
|
+
}];
|
|
894
|
+
const innerEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
895
|
+
el.insert(0, innerEls);
|
|
896
|
+
inner.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
case "codeTree":
|
|
900
|
+
el.setAttribute("files", block.files);
|
|
901
|
+
break;
|
|
902
|
+
case "accordion": {
|
|
903
|
+
const itemEls = block.items.map(() => new Y.XmlElement("accordionItem"));
|
|
904
|
+
el.insert(0, itemEls);
|
|
905
|
+
block.items.forEach((item, i) => {
|
|
906
|
+
itemEls[i].setAttribute("label", item.label);
|
|
907
|
+
if (item.icon) itemEls[i].setAttribute("icon", item.icon);
|
|
908
|
+
const inner = item.innerBlocks.length ? item.innerBlocks : [{
|
|
909
|
+
type: "paragraph",
|
|
910
|
+
text: ""
|
|
911
|
+
}];
|
|
912
|
+
const childEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
913
|
+
itemEls[i].insert(0, childEls);
|
|
914
|
+
inner.forEach((b, ci) => fillBlock(childEls[ci], b));
|
|
915
|
+
});
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
case "tabs": {
|
|
919
|
+
const itemEls = block.items.map(() => new Y.XmlElement("tabsItem"));
|
|
920
|
+
el.insert(0, itemEls);
|
|
921
|
+
block.items.forEach((item, i) => {
|
|
922
|
+
itemEls[i].setAttribute("label", item.label);
|
|
923
|
+
if (item.icon) itemEls[i].setAttribute("icon", item.icon);
|
|
924
|
+
const inner = item.innerBlocks.length ? item.innerBlocks : [{
|
|
925
|
+
type: "paragraph",
|
|
926
|
+
text: ""
|
|
927
|
+
}];
|
|
928
|
+
const childEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
929
|
+
itemEls[i].insert(0, childEls);
|
|
930
|
+
inner.forEach((b, ci) => fillBlock(childEls[ci], b));
|
|
931
|
+
});
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
case "field": {
|
|
935
|
+
if (block.name) el.setAttribute("name", block.name);
|
|
936
|
+
el.setAttribute("type", block.fieldType);
|
|
937
|
+
el.setAttribute("required", block.required);
|
|
938
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{
|
|
939
|
+
type: "paragraph",
|
|
940
|
+
text: ""
|
|
941
|
+
}];
|
|
942
|
+
const innerEls = inner.map((b) => new Y.XmlElement(blockElName(b)));
|
|
943
|
+
el.insert(0, innerEls);
|
|
944
|
+
inner.forEach((b, i) => fillBlock(innerEls[i], b));
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
case "fieldGroup": {
|
|
948
|
+
const fieldEls = block.fields.map((b) => new Y.XmlElement(blockElName(b)));
|
|
949
|
+
el.insert(0, fieldEls);
|
|
950
|
+
block.fields.forEach((b, i) => fillBlock(fieldEls[i], b));
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
case "image":
|
|
954
|
+
el.setAttribute("src", block.src);
|
|
955
|
+
if (block.alt) el.setAttribute("alt", block.alt);
|
|
956
|
+
if (block.width) el.setAttribute("width", block.width);
|
|
957
|
+
if (block.height) el.setAttribute("height", block.height);
|
|
958
|
+
break;
|
|
959
|
+
case "docEmbed":
|
|
960
|
+
el.setAttribute("docId", block.docId);
|
|
961
|
+
for (const flag of [
|
|
962
|
+
"collapsed",
|
|
963
|
+
"tall",
|
|
964
|
+
"seamless"
|
|
965
|
+
]) if (block.props[flag] === "true" || block.props[flag] === "1") el.setAttribute(flag, true);
|
|
966
|
+
break;
|
|
967
|
+
case "mathBlock":
|
|
968
|
+
el.setAttribute("expression", block.expression);
|
|
969
|
+
break;
|
|
970
|
+
case "fileBlock":
|
|
971
|
+
if (block.src) el.setAttribute("src", block.src);
|
|
972
|
+
if (block.mime) el.setAttribute("mime", block.mime);
|
|
973
|
+
if (block.uploadId) el.setAttribute("uploadId", block.uploadId);
|
|
974
|
+
if (block.filename) el.setAttribute("filename", block.filename);
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Parses markdown text and writes the result into a Y.XmlFragment that
|
|
980
|
+
* TipTap's Collaboration extension can read.
|
|
981
|
+
*
|
|
982
|
+
* Requires `fragment.doc` to be set (i.e. the fragment must already be
|
|
983
|
+
* obtained from a live Y.Doc via `ydoc.getXmlFragment('default')`).
|
|
984
|
+
*
|
|
985
|
+
* @param fragment The target `Y.Doc.getXmlFragment('default')`
|
|
986
|
+
* @param markdown Raw markdown string
|
|
987
|
+
* @param fallbackTitle Used as the title when the markdown has no H1
|
|
988
|
+
*/
|
|
989
|
+
function populateYDocFromMarkdown(fragment, markdown, fallbackTitle = "Untitled") {
|
|
990
|
+
const ydoc = fragment.doc;
|
|
991
|
+
if (!ydoc) {
|
|
992
|
+
console.warn("[markdownToYjs] fragment has no doc — skipping population");
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const fm = parseFrontmatter(markdown);
|
|
996
|
+
const blocks = parseBlocks(fm.body);
|
|
997
|
+
let title = fallbackTitle;
|
|
998
|
+
let titleSource;
|
|
999
|
+
if (fm.title !== void 0) {
|
|
1000
|
+
title = fm.title;
|
|
1001
|
+
titleSource = "frontmatter";
|
|
1002
|
+
}
|
|
1003
|
+
let contentBlocks = blocks;
|
|
1004
|
+
const h1 = blocks.findIndex((b) => b.type === "heading" && b.level === 1);
|
|
1005
|
+
if (h1 !== -1) {
|
|
1006
|
+
title = blocks[h1].text;
|
|
1007
|
+
contentBlocks = blocks.filter((_, i) => i !== h1);
|
|
1008
|
+
titleSource = "h1";
|
|
1009
|
+
}
|
|
1010
|
+
ydoc.transact(() => {
|
|
1011
|
+
const headerEl = new Y.XmlElement("documentHeader");
|
|
1012
|
+
const metaEl = new Y.XmlElement("documentMeta");
|
|
1013
|
+
const bodyEls = contentBlocks.map((b) => {
|
|
1014
|
+
switch (b.type) {
|
|
1015
|
+
case "heading": return new Y.XmlElement("heading");
|
|
1016
|
+
case "paragraph": return new Y.XmlElement("paragraph");
|
|
1017
|
+
case "bulletList": return new Y.XmlElement("bulletList");
|
|
1018
|
+
case "orderedList": return new Y.XmlElement("orderedList");
|
|
1019
|
+
case "taskList": return new Y.XmlElement("taskList");
|
|
1020
|
+
case "codeBlock": return new Y.XmlElement("codeBlock");
|
|
1021
|
+
case "blockquote": return new Y.XmlElement("blockquote");
|
|
1022
|
+
case "table": return new Y.XmlElement("table");
|
|
1023
|
+
case "hr": return new Y.XmlElement("horizontalRule");
|
|
1024
|
+
case "callout": return new Y.XmlElement("callout");
|
|
1025
|
+
case "collapsible": return new Y.XmlElement("collapsible");
|
|
1026
|
+
case "steps": return new Y.XmlElement("steps");
|
|
1027
|
+
case "card": return new Y.XmlElement("card");
|
|
1028
|
+
case "cardGroup": return new Y.XmlElement("cardGroup");
|
|
1029
|
+
case "codeCollapse": return new Y.XmlElement("codeCollapse");
|
|
1030
|
+
case "codeGroup": return new Y.XmlElement("codeGroup");
|
|
1031
|
+
case "codePreview": return new Y.XmlElement("codePreview");
|
|
1032
|
+
case "codeTree": return new Y.XmlElement("codeTree");
|
|
1033
|
+
case "accordion": return new Y.XmlElement("accordion");
|
|
1034
|
+
case "tabs": return new Y.XmlElement("tabs");
|
|
1035
|
+
case "field": return new Y.XmlElement("field");
|
|
1036
|
+
case "fieldGroup": return new Y.XmlElement("fieldGroup");
|
|
1037
|
+
case "image": return new Y.XmlElement("image");
|
|
1038
|
+
case "docEmbed": return new Y.XmlElement("docEmbed");
|
|
1039
|
+
case "mathBlock": return new Y.XmlElement("mathBlock");
|
|
1040
|
+
case "fileBlock": return new Y.XmlElement("fileBlock");
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
fragment.insert(0, [
|
|
1044
|
+
headerEl,
|
|
1045
|
+
metaEl,
|
|
1046
|
+
...bodyEls
|
|
1047
|
+
]);
|
|
1048
|
+
if (titleSource) headerEl.setAttribute("titleSource", titleSource);
|
|
1049
|
+
const headerXt = new Y.XmlText();
|
|
1050
|
+
headerEl.insert(0, [headerXt]);
|
|
1051
|
+
headerXt.insert(0, title);
|
|
1052
|
+
for (const k of Object.keys(fm.meta)) {
|
|
1053
|
+
const v = fm.meta[k];
|
|
1054
|
+
if (v === void 0 || v === null) continue;
|
|
1055
|
+
metaEl.setAttribute(k, v);
|
|
1056
|
+
}
|
|
1057
|
+
if (fm.type) metaEl.setAttribute("type", fm.type);
|
|
1058
|
+
contentBlocks.forEach((block, i) => fillBlock(bodyEls[i], block));
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region packages/convert/src/yjs-to-markdown.ts
|
|
1064
|
+
function serializeDelta(delta) {
|
|
1065
|
+
let result = "";
|
|
1066
|
+
for (const op of delta) {
|
|
1067
|
+
if (typeof op.insert !== "string") continue;
|
|
1068
|
+
let text = op.insert;
|
|
1069
|
+
const attrs = op.attributes ?? {};
|
|
1070
|
+
if (attrs.code) {
|
|
1071
|
+
result += `\`${text}\``;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (attrs.badge) {
|
|
1075
|
+
const b = attrs.badge;
|
|
1076
|
+
const props = [];
|
|
1077
|
+
if (b.color && b.color !== "neutral") props.push(`color="${b.color}"`);
|
|
1078
|
+
if (b.variant && b.variant !== "subtle") props.push(`variant="${b.variant}"`);
|
|
1079
|
+
result += `:badge[${b.label || text}]${props.length ? `{${props.join(" ")}}` : ""}`;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (attrs.proseIcon) {
|
|
1083
|
+
const icon = attrs.proseIcon.name || "i-lucide-star";
|
|
1084
|
+
result += `:icon{name="${icon}"}`;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (attrs.kbd) {
|
|
1088
|
+
const value = attrs.kbd.value || text;
|
|
1089
|
+
result += `:kbd{value="${value}"}`;
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
if (attrs.docLink) {
|
|
1093
|
+
const docId = attrs.docLink.docId;
|
|
1094
|
+
if (docId) {
|
|
1095
|
+
result += text === docId ? `[[${docId}]]` : `[[${docId}|${text}]]`;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (attrs.mention) {
|
|
1100
|
+
const { userId, label } = attrs.mention;
|
|
1101
|
+
if (userId) {
|
|
1102
|
+
result += `@[${label || text}](user:${userId})`;
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (attrs.mathInline) {
|
|
1107
|
+
const expr = attrs.mathInline.expression ?? text;
|
|
1108
|
+
result += `$${expr}$`;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (attrs.bold) text = `**${text}**`;
|
|
1112
|
+
if (attrs.italic) text = `*${text}*`;
|
|
1113
|
+
if (attrs.strike) text = `~~${text}~~`;
|
|
1114
|
+
if (attrs.link) {
|
|
1115
|
+
const href = attrs.link.href ?? "";
|
|
1116
|
+
text = `[${text}](${href})`;
|
|
1117
|
+
}
|
|
1118
|
+
result += text;
|
|
1119
|
+
}
|
|
1120
|
+
return result;
|
|
1121
|
+
}
|
|
1122
|
+
function serializeInline(el) {
|
|
1123
|
+
const parts = [];
|
|
1124
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlText) parts.push(serializeDelta(child.toDelta()));
|
|
1125
|
+
else if (child instanceof Y.XmlElement) parts.push(serializeInline(child));
|
|
1126
|
+
return parts.join("");
|
|
1127
|
+
}
|
|
1128
|
+
function serializeBlock(el, indent = "") {
|
|
1129
|
+
if (el instanceof Y.XmlText) return serializeDelta(el.toDelta());
|
|
1130
|
+
switch (el.nodeName) {
|
|
1131
|
+
case "documentHeader":
|
|
1132
|
+
case "documentMeta": return "";
|
|
1133
|
+
case "heading": {
|
|
1134
|
+
const level = Number(el.getAttribute("level") ?? 2);
|
|
1135
|
+
return `${"#".repeat(level)} ${serializeInline(el)}`;
|
|
1136
|
+
}
|
|
1137
|
+
case "paragraph": return serializeInline(el);
|
|
1138
|
+
case "bulletList": return serializeListItems(el, "bullet", indent);
|
|
1139
|
+
case "orderedList": return serializeListItems(el, "ordered", indent);
|
|
1140
|
+
case "taskList": return serializeTaskList(el, indent);
|
|
1141
|
+
case "codeBlock": {
|
|
1142
|
+
const lang = el.getAttribute("language") ?? "";
|
|
1143
|
+
const code = getCodeBlockText(el);
|
|
1144
|
+
if (code === "") return `\`\`\`${lang}\n\`\`\``;
|
|
1145
|
+
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
1146
|
+
}
|
|
1147
|
+
case "blockquote": {
|
|
1148
|
+
const lines = [];
|
|
1149
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlElement) {
|
|
1150
|
+
const text = serializeBlock(child);
|
|
1151
|
+
for (const line of text.split("\n")) lines.push(`> ${line}`);
|
|
1152
|
+
}
|
|
1153
|
+
return lines.join("\n");
|
|
1154
|
+
}
|
|
1155
|
+
case "table": return serializeTable(el);
|
|
1156
|
+
case "horizontalRule": return "---";
|
|
1157
|
+
case "image": {
|
|
1158
|
+
const src = el.getAttribute("src") ?? "";
|
|
1159
|
+
const alt = el.getAttribute("alt") ?? "";
|
|
1160
|
+
const width = el.getAttribute("width");
|
|
1161
|
+
const height = el.getAttribute("height");
|
|
1162
|
+
const attrs = [];
|
|
1163
|
+
if (width) attrs.push(`width=${width}`);
|
|
1164
|
+
if (height) attrs.push(`height=${height}`);
|
|
1165
|
+
return `${attrs.length ? `{${attrs.join(" ")}}` : ""}`;
|
|
1166
|
+
}
|
|
1167
|
+
case "docEmbed": {
|
|
1168
|
+
const docId = el.getAttribute("docId") ?? "";
|
|
1169
|
+
const collapsed = el.getAttribute("collapsed");
|
|
1170
|
+
const tall = el.getAttribute("tall");
|
|
1171
|
+
const seamless = el.getAttribute("seamless");
|
|
1172
|
+
const flags = [];
|
|
1173
|
+
if (collapsed === true || collapsed === "true") flags.push("collapsed");
|
|
1174
|
+
if (tall === true || tall === "true") flags.push("tall");
|
|
1175
|
+
if (seamless === true || seamless === "true") flags.push("seamless");
|
|
1176
|
+
return `![[${docId}]]${flags.length ? `{${flags.join(" ")}}` : ""}`;
|
|
1177
|
+
}
|
|
1178
|
+
case "mathBlock": return `\`\`\`math\n${el.getAttribute("expression") ?? ""}\n\`\`\``;
|
|
1179
|
+
case "fileBlock": {
|
|
1180
|
+
const uploadId = el.getAttribute("uploadId") ?? "";
|
|
1181
|
+
const filename = el.getAttribute("filename") ?? "";
|
|
1182
|
+
const mime = el.getAttribute("mime") ?? "";
|
|
1183
|
+
const src = el.getAttribute("src") ?? (uploadId && filename ? `.abracadabra/files/${uploadId}-${filename}` : "");
|
|
1184
|
+
const props = [];
|
|
1185
|
+
if (src) props.push(`src="${src}"`);
|
|
1186
|
+
if (mime) props.push(`mime="${mime}"`);
|
|
1187
|
+
if (uploadId) props.push(`upload-id="${uploadId}"`);
|
|
1188
|
+
if (filename) props.push(`filename="${filename}"`);
|
|
1189
|
+
return `:file{${props.join(" ")}}`;
|
|
1190
|
+
}
|
|
1191
|
+
case "callout": return `::${el.getAttribute("type") ?? "note"}\n${serializeChildren(el)}\n::`;
|
|
1192
|
+
case "collapsible": {
|
|
1193
|
+
const label = el.getAttribute("label") ?? "Details";
|
|
1194
|
+
const open = el.getAttribute("open");
|
|
1195
|
+
const props = [`label="${label}"`];
|
|
1196
|
+
if (open === true || open === "true") props.push("open=\"true\"");
|
|
1197
|
+
return `::collapsible{${props.join(" ")}}\n${serializeChildren(el)}\n::`;
|
|
1198
|
+
}
|
|
1199
|
+
case "steps": return `::steps\n${serializeChildren(el)}\n::`;
|
|
1200
|
+
case "card": {
|
|
1201
|
+
const props = [];
|
|
1202
|
+
const title = el.getAttribute("title");
|
|
1203
|
+
const icon = el.getAttribute("icon");
|
|
1204
|
+
const to = el.getAttribute("to");
|
|
1205
|
+
if (title) props.push(`title="${title}"`);
|
|
1206
|
+
if (icon) props.push(`icon="${icon}"`);
|
|
1207
|
+
if (to) props.push(`to="${to}"`);
|
|
1208
|
+
return `::card${props.length ? `{${props.join(" ")}}` : ""}\n${serializeChildren(el)}\n::`;
|
|
1209
|
+
}
|
|
1210
|
+
case "cardGroup": return `::card-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1211
|
+
case "codeCollapse": return `::code-collapse\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1212
|
+
case "codeGroup": return `::code-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1213
|
+
case "codePreview": {
|
|
1214
|
+
const children = el.toArray().filter((c) => c instanceof Y.XmlElement);
|
|
1215
|
+
const nonCode = children.filter((c) => c.nodeName !== "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
|
|
1216
|
+
const code = children.filter((c) => c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
|
|
1217
|
+
const parts = [nonCode];
|
|
1218
|
+
if (code) parts.push(`#code\n${code}`);
|
|
1219
|
+
return `::code-preview\n${parts.filter(Boolean).join("\n\n")}\n::`;
|
|
1220
|
+
}
|
|
1221
|
+
case "codeTree": return `::code-tree{files="${el.getAttribute("files") ?? "[]"}"}\n::`;
|
|
1222
|
+
case "accordion": return serializeSlottedContainer(el, "accordion", "accordionItem", "item");
|
|
1223
|
+
case "tabs": return serializeSlottedContainer(el, "tabs", "tabsItem", "tab");
|
|
1224
|
+
case "field": {
|
|
1225
|
+
const fieldName = el.getAttribute("name") ?? "";
|
|
1226
|
+
const fieldType = el.getAttribute("type") ?? "string";
|
|
1227
|
+
const required = el.getAttribute("required");
|
|
1228
|
+
const props = [`name="${fieldName}"`, `type="${fieldType}"`];
|
|
1229
|
+
if (required === true || required === "true") props.push("required=\"true\"");
|
|
1230
|
+
return `::field{${props.join(" ")}}\n${serializeChildren(el)}\n::`;
|
|
1231
|
+
}
|
|
1232
|
+
case "fieldGroup": return `::field-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
|
|
1233
|
+
default: return serializeChildren(el);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function serializeChildren(el) {
|
|
1237
|
+
const blocks = [];
|
|
1238
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlElement) {
|
|
1239
|
+
const text = serializeBlock(child);
|
|
1240
|
+
if (text) blocks.push(text);
|
|
1241
|
+
} else if (child instanceof Y.XmlText) {
|
|
1242
|
+
const text = serializeDelta(child.toDelta());
|
|
1243
|
+
if (text) blocks.push(text);
|
|
1244
|
+
}
|
|
1245
|
+
return blocks.join("\n\n");
|
|
1246
|
+
}
|
|
1247
|
+
function serializeListItems(el, type, indent) {
|
|
1248
|
+
const lines = [];
|
|
1249
|
+
let counter = 1;
|
|
1250
|
+
for (const child of el.toArray()) {
|
|
1251
|
+
if (!(child instanceof Y.XmlElement) || child.nodeName !== "listItem") continue;
|
|
1252
|
+
const prefix = type === "bullet" ? "- " : `${counter++}. `;
|
|
1253
|
+
const subParts = [];
|
|
1254
|
+
for (const sub of child.toArray()) {
|
|
1255
|
+
if (!(sub instanceof Y.XmlElement)) continue;
|
|
1256
|
+
if (sub.nodeName === "bulletList") subParts.push(serializeListItems(sub, "bullet", indent + " "));
|
|
1257
|
+
else if (sub.nodeName === "orderedList") subParts.push(serializeListItems(sub, "ordered", indent + " "));
|
|
1258
|
+
else subParts.push(serializeInline(sub));
|
|
1259
|
+
}
|
|
1260
|
+
if (subParts.length <= 1) lines.push(`${indent}${prefix}${subParts[0] ?? ""}`);
|
|
1261
|
+
else {
|
|
1262
|
+
lines.push(`${indent}${prefix}${subParts[0] ?? ""}`);
|
|
1263
|
+
for (let i = 1; i < subParts.length; i++) lines.push(subParts[i]);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return lines.join("\n");
|
|
1267
|
+
}
|
|
1268
|
+
function serializeTaskList(el, indent) {
|
|
1269
|
+
const lines = [];
|
|
1270
|
+
for (const child of el.toArray()) {
|
|
1271
|
+
if (!(child instanceof Y.XmlElement) || child.nodeName !== "taskItem") continue;
|
|
1272
|
+
const checked = child.getAttribute("checked");
|
|
1273
|
+
const marker = checked === true || checked === "true" ? "[x]" : "[ ]";
|
|
1274
|
+
let header = "";
|
|
1275
|
+
const nestedParts = [];
|
|
1276
|
+
for (const sub of child.toArray()) {
|
|
1277
|
+
if (!(sub instanceof Y.XmlElement)) continue;
|
|
1278
|
+
if (sub.nodeName === "paragraph" && header === "") header = serializeInline(sub);
|
|
1279
|
+
else if (sub.nodeName === "bulletList") nestedParts.push(serializeListItems(sub, "bullet", indent + " "));
|
|
1280
|
+
else if (sub.nodeName === "orderedList") nestedParts.push(serializeListItems(sub, "ordered", indent + " "));
|
|
1281
|
+
else if (sub.nodeName === "taskList") nestedParts.push(serializeTaskList(sub, indent + " "));
|
|
1282
|
+
else nestedParts.push(indent + " " + serializeBlock(sub, indent + " "));
|
|
1283
|
+
}
|
|
1284
|
+
lines.push(`${indent}- ${marker} ${header}`);
|
|
1285
|
+
for (const part of nestedParts) lines.push(part);
|
|
1286
|
+
}
|
|
1287
|
+
return lines.join("\n");
|
|
1288
|
+
}
|
|
1289
|
+
function getCodeBlockText(el) {
|
|
1290
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlText) return child.toString();
|
|
1291
|
+
return "";
|
|
1292
|
+
}
|
|
1293
|
+
function serializeTable(el) {
|
|
1294
|
+
const rows = el.toArray().filter((c) => c instanceof Y.XmlElement);
|
|
1295
|
+
if (!rows.length) return "";
|
|
1296
|
+
const serializedRows = [];
|
|
1297
|
+
for (const row of rows) {
|
|
1298
|
+
const cells = row.toArray().filter((c) => c instanceof Y.XmlElement).map((cell) => {
|
|
1299
|
+
return cell.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeInline(c)).join(" ");
|
|
1300
|
+
});
|
|
1301
|
+
serializedRows.push(cells);
|
|
1302
|
+
}
|
|
1303
|
+
if (!serializedRows.length) return "";
|
|
1304
|
+
const colCount = Math.max(...serializedRows.map((r) => r.length));
|
|
1305
|
+
const headerRow = serializedRows[0];
|
|
1306
|
+
const separator = Array(colCount).fill("---");
|
|
1307
|
+
const dataRows = serializedRows.slice(1);
|
|
1308
|
+
const formatRow = (cells) => {
|
|
1309
|
+
return `| ${Array(colCount).fill("").map((_, i) => cells[i] ?? "").join(" | ")} |`;
|
|
1310
|
+
};
|
|
1311
|
+
return [
|
|
1312
|
+
formatRow(headerRow),
|
|
1313
|
+
formatRow(separator),
|
|
1314
|
+
...dataRows.map(formatRow)
|
|
1315
|
+
].join("\n");
|
|
1316
|
+
}
|
|
1317
|
+
function serializeSlottedContainer(el, containerName, childName, slotPrefix) {
|
|
1318
|
+
return `::${containerName}\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === childName).map((item) => {
|
|
1319
|
+
const label = item.getAttribute("label") ?? "";
|
|
1320
|
+
const icon = item.getAttribute("icon") ?? "";
|
|
1321
|
+
const props = [];
|
|
1322
|
+
if (label) props.push(`label="${label}"`);
|
|
1323
|
+
if (icon) props.push(`icon="${icon}"`);
|
|
1324
|
+
const content = serializeChildren(item);
|
|
1325
|
+
return `#${slotPrefix}{${props.join(" ")}}\n${content}`;
|
|
1326
|
+
}).join("\n\n")}\n::`;
|
|
1327
|
+
}
|
|
1328
|
+
function generateFrontmatter(label, meta, type) {
|
|
1329
|
+
const lines = [];
|
|
1330
|
+
if (label !== void 0) lines.push(`title: "${escapeYaml(label)}"`);
|
|
1331
|
+
if (type && type !== "doc") lines.push(`type: ${type}`);
|
|
1332
|
+
if (!meta) return `---\n${lines.join("\n")}\n---`;
|
|
1333
|
+
if (meta.tags?.length) lines.push(`tags: [${meta.tags.join(", ")}]`);
|
|
1334
|
+
if (meta.color) lines.push(`color: ${yamlScalar(meta.color)}`);
|
|
1335
|
+
if (meta.icon) lines.push(`icon: ${yamlScalar(meta.icon)}`);
|
|
1336
|
+
if (meta.status) lines.push(`status: ${yamlScalar(meta.status)}`);
|
|
1337
|
+
if (meta.priority !== void 0 && meta.priority !== 0) lines.push(`priority: ${{
|
|
1338
|
+
1: "low",
|
|
1339
|
+
2: "medium",
|
|
1340
|
+
3: "high",
|
|
1341
|
+
4: "urgent"
|
|
1342
|
+
}[meta.priority] ?? meta.priority}`);
|
|
1343
|
+
if (meta.checked !== void 0) lines.push(`checked: ${meta.checked}`);
|
|
1344
|
+
if (meta.dateStart) lines.push(`dateStart: "${escapeYaml(meta.dateStart)}"`);
|
|
1345
|
+
if (meta.dateEnd) lines.push(`dateEnd: "${escapeYaml(meta.dateEnd)}"`);
|
|
1346
|
+
if (meta.subtitle) lines.push(`subtitle: "${escapeYaml(meta.subtitle)}"`);
|
|
1347
|
+
if (meta.url) lines.push(`url: ${meta.url}`);
|
|
1348
|
+
if (meta.rating !== void 0 && meta.rating !== 0) lines.push(`rating: ${meta.rating}`);
|
|
1349
|
+
return `---\n${lines.join("\n")}\n---`;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Render a YAML scalar — bare when safe, double-quoted when the value
|
|
1353
|
+
* needs escaping. YAML treats `#`, `:`, leading whitespace, and a few
|
|
1354
|
+
* other characters as syntactically significant, so anything starting
|
|
1355
|
+
* with one of those gets quoted to stay round-trip safe.
|
|
1356
|
+
*/
|
|
1357
|
+
function yamlScalar(s) {
|
|
1358
|
+
if (s === "") return "\"\"";
|
|
1359
|
+
if (/^[#&*!|>%@`]/.test(s)) return `"${escapeYaml(s)}"`;
|
|
1360
|
+
if (/[:"]/.test(s)) return `"${escapeYaml(s)}"`;
|
|
1361
|
+
if (/^\s|\s$/.test(s)) return `"${escapeYaml(s)}"`;
|
|
1362
|
+
return s;
|
|
1363
|
+
}
|
|
1364
|
+
function escapeYaml(s) {
|
|
1365
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
1366
|
+
}
|
|
1367
|
+
function serializeBlockToHtml(el) {
|
|
1368
|
+
if (el instanceof Y.XmlText) return serializeDeltaToHtml(el.toDelta());
|
|
1369
|
+
const name = el.nodeName;
|
|
1370
|
+
switch (name) {
|
|
1371
|
+
case "documentHeader":
|
|
1372
|
+
case "documentMeta": return "";
|
|
1373
|
+
case "heading": {
|
|
1374
|
+
const level = Number(el.getAttribute("level") ?? 2);
|
|
1375
|
+
return `<h${level}>${serializeInlineHtml(el)}</h${level}>`;
|
|
1376
|
+
}
|
|
1377
|
+
case "paragraph": return `<p>${serializeInlineHtml(el)}</p>`;
|
|
1378
|
+
case "bulletList": return `<ul>${serializeListHtml(el)}</ul>`;
|
|
1379
|
+
case "orderedList": return `<ol>${serializeListHtml(el)}</ol>`;
|
|
1380
|
+
case "taskList": return `<ul>${serializeTaskListHtml(el)}</ul>`;
|
|
1381
|
+
case "codeBlock": {
|
|
1382
|
+
const lang = el.getAttribute("language") ?? "";
|
|
1383
|
+
const code = escapeHtml(getCodeBlockText(el));
|
|
1384
|
+
return lang ? `<pre><code class="language-${lang}">${code}</code></pre>` : `<pre><code>${code}</code></pre>`;
|
|
1385
|
+
}
|
|
1386
|
+
case "blockquote": return `<blockquote>\n${el.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlockToHtml(c)).join("\n")}\n</blockquote>`;
|
|
1387
|
+
case "table": return serializeTableHtml(el);
|
|
1388
|
+
case "horizontalRule": return "<hr>";
|
|
1389
|
+
case "image": {
|
|
1390
|
+
const src = el.getAttribute("src") ?? "";
|
|
1391
|
+
const alt = el.getAttribute("alt") ?? "";
|
|
1392
|
+
return `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">`;
|
|
1393
|
+
}
|
|
1394
|
+
case "fileBlock": {
|
|
1395
|
+
const uploadId = el.getAttribute("uploadId") ?? "";
|
|
1396
|
+
const filename = el.getAttribute("filename") ?? "file";
|
|
1397
|
+
if (uploadId) return `<!--fileblock:${uploadId}:${filename}-->`;
|
|
1398
|
+
return `<!-- file: ${filename} -->`;
|
|
1399
|
+
}
|
|
1400
|
+
default: return `<div data-type="${name}">\n${el.toArray().filter((c) => c instanceof Y.XmlElement || c instanceof Y.XmlText).map((c) => c instanceof Y.XmlElement ? serializeBlockToHtml(c) : serializeDeltaToHtml(c.toDelta())).join("\n")}\n</div>`;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function serializeInlineHtml(el) {
|
|
1404
|
+
const parts = [];
|
|
1405
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlText) parts.push(serializeDeltaToHtml(child.toDelta()));
|
|
1406
|
+
else if (child instanceof Y.XmlElement) parts.push(serializeInlineHtml(child));
|
|
1407
|
+
return parts.join("");
|
|
1408
|
+
}
|
|
1409
|
+
function serializeDeltaToHtml(delta) {
|
|
1410
|
+
let result = "";
|
|
1411
|
+
for (const op of delta) {
|
|
1412
|
+
if (typeof op.insert !== "string") continue;
|
|
1413
|
+
let text = escapeHtml(op.insert);
|
|
1414
|
+
const attrs = op.attributes ?? {};
|
|
1415
|
+
if (attrs.code) text = `<code>${text}</code>`;
|
|
1416
|
+
if (attrs.bold) text = `<strong>${text}</strong>`;
|
|
1417
|
+
if (attrs.italic) text = `<em>${text}</em>`;
|
|
1418
|
+
if (attrs.strike) text = `<s>${text}</s>`;
|
|
1419
|
+
if (attrs.link) text = `<a href="${escapeHtml(attrs.link.href ?? "")}">${text}</a>`;
|
|
1420
|
+
result += text;
|
|
1421
|
+
}
|
|
1422
|
+
return result;
|
|
1423
|
+
}
|
|
1424
|
+
function serializeListHtml(el) {
|
|
1425
|
+
return el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "listItem").map((li) => `<li>${li.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlockToHtml(c)).join("")}</li>`).join("\n");
|
|
1426
|
+
}
|
|
1427
|
+
function serializeTaskListHtml(el) {
|
|
1428
|
+
return el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "taskItem").map((ti) => {
|
|
1429
|
+
const rawChecked = ti.getAttribute("checked");
|
|
1430
|
+
const checked = rawChecked === true || rawChecked === "true";
|
|
1431
|
+
const text = ti.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeInlineHtml(c)).join("");
|
|
1432
|
+
return `<li><input type="checkbox"${checked ? " checked" : ""} disabled> ${text}</li>`;
|
|
1433
|
+
}).join("\n");
|
|
1434
|
+
}
|
|
1435
|
+
function serializeTableHtml(el) {
|
|
1436
|
+
const rows = el.toArray().filter((c) => c instanceof Y.XmlElement);
|
|
1437
|
+
if (!rows.length) return "";
|
|
1438
|
+
return `<table>\n${rows.map((row, ri) => {
|
|
1439
|
+
const tag = ri === 0 ? "th" : "td";
|
|
1440
|
+
return `<tr>${row.toArray().filter((c) => c instanceof Y.XmlElement).map((cell) => {
|
|
1441
|
+
return `<${tag}>${cell.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeInlineHtml(c)).join("")}</${tag}>`;
|
|
1442
|
+
}).join("")}</tr>`;
|
|
1443
|
+
}).join("\n")}\n</table>`;
|
|
1444
|
+
}
|
|
1445
|
+
function escapeHtml(s) {
|
|
1446
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1447
|
+
}
|
|
1448
|
+
function yjsToMarkdown(fragment, label, meta, type) {
|
|
1449
|
+
const { text: headerText, source: titleSource } = readDocumentHeader(fragment);
|
|
1450
|
+
const effectiveTitle = headerText || label;
|
|
1451
|
+
const docMeta = readDocumentMeta(fragment);
|
|
1452
|
+
const effectiveMeta = meta ?? docMeta.meta;
|
|
1453
|
+
const effectiveType = type ?? docMeta.type;
|
|
1454
|
+
const metaIsEmpty = isMetaEmpty(effectiveMeta);
|
|
1455
|
+
const typeIsDefault = !effectiveType || effectiveType === "doc";
|
|
1456
|
+
const bodyBlocks = collectBodyBlocks(fragment);
|
|
1457
|
+
let body;
|
|
1458
|
+
if (titleSource === "h1" && effectiveTitle) {
|
|
1459
|
+
const tail = serializeBlocksClean(bodyBlocks);
|
|
1460
|
+
body = tail === "" ? `# ${effectiveTitle}` : `# ${effectiveTitle}\n\n${tail}`;
|
|
1461
|
+
} else body = serializeBlocksClean(bodyBlocks);
|
|
1462
|
+
const wantFrontmatterTitle = titleSource === "frontmatter";
|
|
1463
|
+
if (!wantFrontmatterTitle && !(!metaIsEmpty || !typeIsDefault)) return body === "" ? "" : `${body}\n`;
|
|
1464
|
+
const frontmatter = generateFrontmatter(wantFrontmatterTitle ? effectiveTitle : void 0, effectiveMeta, effectiveType);
|
|
1465
|
+
if (body === "") return `${frontmatter}\n`;
|
|
1466
|
+
return `${frontmatter}\n\n${body}\n`;
|
|
1467
|
+
}
|
|
1468
|
+
function readDocumentMeta(fragment) {
|
|
1469
|
+
const meta = {};
|
|
1470
|
+
let type;
|
|
1471
|
+
for (const child of fragment.toArray()) {
|
|
1472
|
+
if (!(child instanceof Y.XmlElement) || child.nodeName !== "documentMeta") continue;
|
|
1473
|
+
const attrs = child.getAttributes();
|
|
1474
|
+
for (const k of Object.keys(attrs)) {
|
|
1475
|
+
const v = attrs[k];
|
|
1476
|
+
if (v === void 0 || v === null) continue;
|
|
1477
|
+
if (k === "type" && typeof v === "string") {
|
|
1478
|
+
type = v;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
meta[k] = v;
|
|
1482
|
+
}
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
meta,
|
|
1487
|
+
type
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
function readDocumentHeader(fragment) {
|
|
1491
|
+
for (const child of fragment.toArray()) {
|
|
1492
|
+
if (!(child instanceof Y.XmlElement) || child.nodeName !== "documentHeader") continue;
|
|
1493
|
+
const text = child.toArray().find((c) => c instanceof Y.XmlText);
|
|
1494
|
+
const src = child.getAttribute("titleSource");
|
|
1495
|
+
const source = src === "h1" || src === "frontmatter" ? src : void 0;
|
|
1496
|
+
return {
|
|
1497
|
+
text: text ? text.toString() : "",
|
|
1498
|
+
source
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
return { text: "" };
|
|
1502
|
+
}
|
|
1503
|
+
function collectBodyBlocks(fragment) {
|
|
1504
|
+
const out = [];
|
|
1505
|
+
for (const child of fragment.toArray()) {
|
|
1506
|
+
if (!(child instanceof Y.XmlElement)) continue;
|
|
1507
|
+
if (child.nodeName === "documentHeader" || child.nodeName === "documentMeta") continue;
|
|
1508
|
+
out.push(child);
|
|
1509
|
+
}
|
|
1510
|
+
return out;
|
|
1511
|
+
}
|
|
1512
|
+
function serializeBlocksClean(blocks) {
|
|
1513
|
+
const parts = [];
|
|
1514
|
+
for (const block of blocks) {
|
|
1515
|
+
if (block.nodeName === "paragraph" && block.length === 0) {
|
|
1516
|
+
parts.push("");
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
parts.push(serializeBlock(block));
|
|
1520
|
+
}
|
|
1521
|
+
while (parts.length && parts[parts.length - 1] === "") parts.pop();
|
|
1522
|
+
return parts.join("\n\n");
|
|
1523
|
+
}
|
|
1524
|
+
function isMetaEmpty(meta) {
|
|
1525
|
+
if (!meta) return true;
|
|
1526
|
+
for (const key of Object.keys(meta)) {
|
|
1527
|
+
const v = meta[key];
|
|
1528
|
+
if (v === void 0 || v === null) continue;
|
|
1529
|
+
if (typeof v === "string" && v === "") continue;
|
|
1530
|
+
if (Array.isArray(v) && v.length === 0) continue;
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Walk the Y.XmlFragment and concatenate all text content with no
|
|
1537
|
+
* markup or frontmatter. Block boundaries become newlines. Useful for
|
|
1538
|
+
* accessibility tooling, search indexing, and snippet previews.
|
|
1539
|
+
*/
|
|
1540
|
+
function yjsToPlainText(fragment) {
|
|
1541
|
+
const out = [];
|
|
1542
|
+
const visit = (node) => {
|
|
1543
|
+
if (node instanceof Y.XmlText) {
|
|
1544
|
+
out.push(node.toString());
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (node.nodeName === "documentMeta") return;
|
|
1548
|
+
if (node.nodeName === "image") {
|
|
1549
|
+
const alt = node.getAttribute("alt") ?? "";
|
|
1550
|
+
if (alt) out.push(alt);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
for (const child of node.toArray()) if (child instanceof Y.XmlText || child instanceof Y.XmlElement) visit(child);
|
|
1554
|
+
if (node.nodeName !== "paragraph" && node.length === 0) return;
|
|
1555
|
+
out.push("\n");
|
|
1556
|
+
};
|
|
1557
|
+
for (const child of fragment.toArray()) if (child instanceof Y.XmlText || child instanceof Y.XmlElement) visit(child);
|
|
1558
|
+
return out.join("").replace(/\n+$/, "").replace(/\n{3,}/g, "\n\n");
|
|
1559
|
+
}
|
|
1560
|
+
function yjsToHtml(fragment, label) {
|
|
1561
|
+
const title = escapeHtml(label);
|
|
1562
|
+
const bodyParts = [];
|
|
1563
|
+
for (const child of fragment.toArray()) if (child instanceof Y.XmlElement) {
|
|
1564
|
+
const html = serializeBlockToHtml(child);
|
|
1565
|
+
if (html) bodyParts.push(html);
|
|
1566
|
+
}
|
|
1567
|
+
return `<!DOCTYPE html>
|
|
1568
|
+
<html>
|
|
1569
|
+
<head><meta charset="utf-8"><title>${title}</title></head>
|
|
1570
|
+
<body>
|
|
1571
|
+
<h1>${title}</h1>
|
|
1572
|
+
${bodyParts.join("\n")}
|
|
1573
|
+
</body>
|
|
1574
|
+
</html>
|
|
1575
|
+
`;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region packages/convert/src/html-to-yjs.ts
|
|
1580
|
+
function getTextContent(node) {
|
|
1581
|
+
return node.textContent ?? "";
|
|
1582
|
+
}
|
|
1583
|
+
function langFromClass(el) {
|
|
1584
|
+
for (const cls of Array.from(el.classList)) if (cls.startsWith("language-")) return cls.slice(9);
|
|
1585
|
+
return "";
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Collect inline marks from a DOM element's ancestry.
|
|
1589
|
+
* Returns merged marks object accumulated from the provided stack.
|
|
1590
|
+
*/
|
|
1591
|
+
function mergeMarks(stack) {
|
|
1592
|
+
const merged = {};
|
|
1593
|
+
for (const m of stack) {
|
|
1594
|
+
if (m.bold) merged.bold = true;
|
|
1595
|
+
if (m.italic) merged.italic = true;
|
|
1596
|
+
if (m.code) merged.code = true;
|
|
1597
|
+
if (m.strike) merged.strike = true;
|
|
1598
|
+
if (m.link) merged.link = m.link;
|
|
1599
|
+
}
|
|
1600
|
+
return merged;
|
|
1601
|
+
}
|
|
1602
|
+
/** Walk an inline subtree and collect { text, marks } runs. */
|
|
1603
|
+
function collectInlineRuns(node, markStack) {
|
|
1604
|
+
const runs = [];
|
|
1605
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1606
|
+
const text = node.textContent ?? "";
|
|
1607
|
+
if (text) runs.push({
|
|
1608
|
+
text,
|
|
1609
|
+
marks: mergeMarks(markStack)
|
|
1610
|
+
});
|
|
1611
|
+
return runs;
|
|
1612
|
+
}
|
|
1613
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return runs;
|
|
1614
|
+
const el = node;
|
|
1615
|
+
const tag = el.tagName.toLowerCase();
|
|
1616
|
+
const newMarks = {};
|
|
1617
|
+
if (tag === "strong" || tag === "b") newMarks.bold = true;
|
|
1618
|
+
if (tag === "em" || tag === "i") newMarks.italic = true;
|
|
1619
|
+
if (tag === "code") newMarks.code = true;
|
|
1620
|
+
if (tag === "s" || tag === "del" || tag === "strike") newMarks.strike = true;
|
|
1621
|
+
if (tag === "a") {
|
|
1622
|
+
const href = el.getAttribute("href");
|
|
1623
|
+
if (href) newMarks.link = { href };
|
|
1624
|
+
}
|
|
1625
|
+
const nextStack = Object.keys(newMarks).length ? [...markStack, newMarks] : markStack;
|
|
1626
|
+
for (const child of Array.from(el.childNodes)) runs.push(...collectInlineRuns(child, nextStack));
|
|
1627
|
+
return runs;
|
|
1628
|
+
}
|
|
1629
|
+
/** Fill an already-attached Y.XmlElement with inline text runs. */
|
|
1630
|
+
function fillInlineRuns(paraEl, runs) {
|
|
1631
|
+
if (!runs.length) return;
|
|
1632
|
+
const xtNodes = runs.map(() => new Y.XmlText());
|
|
1633
|
+
paraEl.insert(0, xtNodes);
|
|
1634
|
+
runs.forEach((run, i) => {
|
|
1635
|
+
const attrs = {};
|
|
1636
|
+
if (run.marks.bold) attrs["bold"] = true;
|
|
1637
|
+
if (run.marks.italic) attrs["italic"] = true;
|
|
1638
|
+
if (run.marks.code) attrs["code"] = true;
|
|
1639
|
+
if (run.marks.strike) attrs["strike"] = true;
|
|
1640
|
+
if (run.marks.link) attrs["link"] = run.marks.link;
|
|
1641
|
+
if (Object.keys(attrs).length) xtNodes[i].insert(0, run.text, attrs);
|
|
1642
|
+
else xtNodes[i].insert(0, run.text);
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
/** Convert a single block-level DOM element to Y.XmlElement(s) and append to `container`. */
|
|
1646
|
+
function convertBlockElement(el, container) {
|
|
1647
|
+
const tag = el.tagName.toLowerCase();
|
|
1648
|
+
if (tag === "div" && el.getAttribute("data-type") === "file-block") {
|
|
1649
|
+
const fileBlockEl = new Y.XmlElement("fileBlock");
|
|
1650
|
+
container.insert(container.length, [fileBlockEl]);
|
|
1651
|
+
const uploadId = el.getAttribute("uploadid") ?? "";
|
|
1652
|
+
const docId = el.getAttribute("docid") ?? "";
|
|
1653
|
+
const filename = el.getAttribute("filename") ?? "";
|
|
1654
|
+
const mimeType = el.getAttribute("mimetype") ?? "";
|
|
1655
|
+
if (uploadId) fileBlockEl.setAttribute("uploadId", uploadId);
|
|
1656
|
+
if (docId) fileBlockEl.setAttribute("docId", docId);
|
|
1657
|
+
if (filename) fileBlockEl.setAttribute("filename", filename);
|
|
1658
|
+
if (mimeType) fileBlockEl.setAttribute("mimeType", mimeType);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const headingMatch = tag.match(/^h([1-6])$/);
|
|
1662
|
+
if (headingMatch) {
|
|
1663
|
+
const level = parseInt(headingMatch[1]);
|
|
1664
|
+
const headingEl = new Y.XmlElement("heading");
|
|
1665
|
+
container.insert(container.length, [headingEl]);
|
|
1666
|
+
headingEl.setAttribute("level", level);
|
|
1667
|
+
fillInlineRuns(headingEl, collectInlineRuns(el, []));
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (tag === "p") {
|
|
1671
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1672
|
+
container.insert(container.length, [paraEl]);
|
|
1673
|
+
fillInlineRuns(paraEl, collectInlineRuns(el, []));
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (tag === "hr") {
|
|
1677
|
+
const hrEl = new Y.XmlElement("horizontalRule");
|
|
1678
|
+
container.insert(container.length, [hrEl]);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (tag === "pre") {
|
|
1682
|
+
const codeEl = el.querySelector("code");
|
|
1683
|
+
const codeBlock = new Y.XmlElement("codeBlock");
|
|
1684
|
+
container.insert(container.length, [codeBlock]);
|
|
1685
|
+
const lang = codeEl ? langFromClass(codeEl) : "";
|
|
1686
|
+
if (lang) codeBlock.setAttribute("language", lang);
|
|
1687
|
+
const xt = new Y.XmlText();
|
|
1688
|
+
codeBlock.insert(0, [xt]);
|
|
1689
|
+
xt.insert(0, (codeEl ?? el).textContent ?? "");
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
if (tag === "blockquote") {
|
|
1693
|
+
const bqEl = new Y.XmlElement("blockquote");
|
|
1694
|
+
container.insert(container.length, [bqEl]);
|
|
1695
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1696
|
+
bqEl.insert(0, [paraEl]);
|
|
1697
|
+
fillInlineRuns(paraEl, collectInlineRuns(el, []));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (tag === "ul" || tag === "ol") {
|
|
1701
|
+
const items = Array.from(el.querySelectorAll(":scope > li"));
|
|
1702
|
+
if (items.some((li) => li.querySelector("input[type=\"checkbox\"]"))) {
|
|
1703
|
+
const taskListEl = new Y.XmlElement("taskList");
|
|
1704
|
+
container.insert(container.length, [taskListEl]);
|
|
1705
|
+
const taskItemEls = items.map(() => new Y.XmlElement("taskItem"));
|
|
1706
|
+
taskListEl.insert(0, taskItemEls);
|
|
1707
|
+
items.forEach((li, i) => {
|
|
1708
|
+
const checked = li.querySelector("input[type=\"checkbox\"]")?.checked ?? false;
|
|
1709
|
+
taskItemEls[i].setAttribute("checked", checked);
|
|
1710
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1711
|
+
taskItemEls[i].insert(0, [paraEl]);
|
|
1712
|
+
const clone = li.cloneNode(true);
|
|
1713
|
+
clone.querySelector("input")?.remove();
|
|
1714
|
+
fillInlineRuns(paraEl, collectInlineRuns(clone, []));
|
|
1715
|
+
});
|
|
1716
|
+
} else {
|
|
1717
|
+
const listType = tag === "ul" ? "bulletList" : "orderedList";
|
|
1718
|
+
const listEl = new Y.XmlElement(listType);
|
|
1719
|
+
container.insert(container.length, [listEl]);
|
|
1720
|
+
const listItemEls = items.map(() => new Y.XmlElement("listItem"));
|
|
1721
|
+
listEl.insert(0, listItemEls);
|
|
1722
|
+
items.forEach((li, i) => {
|
|
1723
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1724
|
+
listItemEls[i].insert(0, [paraEl]);
|
|
1725
|
+
fillInlineRuns(paraEl, collectInlineRuns(li, []));
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (tag === "table") {
|
|
1731
|
+
const tableEl = new Y.XmlElement("table");
|
|
1732
|
+
container.insert(container.length, [tableEl]);
|
|
1733
|
+
const rows = Array.from(el.querySelectorAll("tr"));
|
|
1734
|
+
const rowEls = rows.map(() => new Y.XmlElement("tableRow"));
|
|
1735
|
+
tableEl.insert(0, rowEls);
|
|
1736
|
+
rows.forEach((row, ri) => {
|
|
1737
|
+
const cells = Array.from(row.querySelectorAll("th, td"));
|
|
1738
|
+
const cellType = cells.some((c) => c.tagName.toLowerCase() === "th") ? "tableHeader" : "tableCell";
|
|
1739
|
+
const cellEls = cells.map(() => new Y.XmlElement(cellType));
|
|
1740
|
+
rowEls[ri].insert(0, cellEls);
|
|
1741
|
+
cells.forEach((cell, ci) => {
|
|
1742
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1743
|
+
cellEls[ci].insert(0, [paraEl]);
|
|
1744
|
+
fillInlineRuns(paraEl, collectInlineRuns(cell, []));
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
if (getTextContent(el).trim()) {
|
|
1750
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1751
|
+
container.insert(container.length, [paraEl]);
|
|
1752
|
+
fillInlineRuns(paraEl, collectInlineRuns(el, []));
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Parses an HTML string and writes the result into a Y.XmlFragment that
|
|
1757
|
+
* TipTap's Collaboration extension can read.
|
|
1758
|
+
*
|
|
1759
|
+
* @param fragment The target `Y.Doc.getXmlFragment('default')`
|
|
1760
|
+
* @param html Raw HTML string
|
|
1761
|
+
* @param fallbackTitle Used when no <title> or <h1> is found
|
|
1762
|
+
*/
|
|
1763
|
+
function populateYDocFromHtml(fragment, html, fallbackTitle = "Untitled") {
|
|
1764
|
+
const ydoc = fragment.doc;
|
|
1765
|
+
if (!ydoc) {
|
|
1766
|
+
console.warn("[htmlToYjs] fragment has no doc — skipping population");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
1770
|
+
let title = doc.title?.trim() || fallbackTitle;
|
|
1771
|
+
const firstH1 = doc.body.querySelector("h1");
|
|
1772
|
+
if (firstH1) title = firstH1.textContent?.trim() || title;
|
|
1773
|
+
ydoc.transact(() => {
|
|
1774
|
+
const headerEl = new Y.XmlElement("documentHeader");
|
|
1775
|
+
const metaEl = new Y.XmlElement("documentMeta");
|
|
1776
|
+
fragment.insert(0, [headerEl, metaEl]);
|
|
1777
|
+
const headerXt = new Y.XmlText();
|
|
1778
|
+
headerEl.insert(0, [headerXt]);
|
|
1779
|
+
headerXt.insert(0, title);
|
|
1780
|
+
const blockTags = new Set([
|
|
1781
|
+
"p",
|
|
1782
|
+
"h1",
|
|
1783
|
+
"h2",
|
|
1784
|
+
"h3",
|
|
1785
|
+
"h4",
|
|
1786
|
+
"h5",
|
|
1787
|
+
"h6",
|
|
1788
|
+
"ul",
|
|
1789
|
+
"ol",
|
|
1790
|
+
"pre",
|
|
1791
|
+
"blockquote",
|
|
1792
|
+
"table",
|
|
1793
|
+
"hr",
|
|
1794
|
+
"div",
|
|
1795
|
+
"section",
|
|
1796
|
+
"article",
|
|
1797
|
+
"header",
|
|
1798
|
+
"footer",
|
|
1799
|
+
"main",
|
|
1800
|
+
"aside"
|
|
1801
|
+
]);
|
|
1802
|
+
let hasContent = false;
|
|
1803
|
+
for (const child of Array.from(doc.body.children)) {
|
|
1804
|
+
const tag = child.tagName.toLowerCase();
|
|
1805
|
+
if (!blockTags.has(tag)) continue;
|
|
1806
|
+
if (tag === "h1" && child === firstH1) continue;
|
|
1807
|
+
convertBlockElement(child, fragment);
|
|
1808
|
+
hasContent = true;
|
|
1809
|
+
}
|
|
1810
|
+
if (!hasContent) {
|
|
1811
|
+
const paraEl = new Y.XmlElement("paragraph");
|
|
1812
|
+
fragment.insert(fragment.length, [paraEl]);
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Appends blocks from an HTML string to an existing Y.XmlFragment.
|
|
1818
|
+
* Does NOT insert documentHeader/documentMeta — for appending to an existing doc.
|
|
1819
|
+
*/
|
|
1820
|
+
function appendHtmlToFragment(fragment, html) {
|
|
1821
|
+
const ydoc = fragment.doc;
|
|
1822
|
+
if (!ydoc) {
|
|
1823
|
+
console.warn("[htmlToYjs] appendHtmlToFragment: fragment has no doc — skipping");
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
1827
|
+
const blockTags = new Set([
|
|
1828
|
+
"p",
|
|
1829
|
+
"h1",
|
|
1830
|
+
"h2",
|
|
1831
|
+
"h3",
|
|
1832
|
+
"h4",
|
|
1833
|
+
"h5",
|
|
1834
|
+
"h6",
|
|
1835
|
+
"ul",
|
|
1836
|
+
"ol",
|
|
1837
|
+
"pre",
|
|
1838
|
+
"blockquote",
|
|
1839
|
+
"table",
|
|
1840
|
+
"hr",
|
|
1841
|
+
"div",
|
|
1842
|
+
"section",
|
|
1843
|
+
"article",
|
|
1844
|
+
"header",
|
|
1845
|
+
"footer",
|
|
1846
|
+
"main",
|
|
1847
|
+
"aside"
|
|
1848
|
+
]);
|
|
1849
|
+
ydoc.transact(() => {
|
|
1850
|
+
for (const child of Array.from(doc.body.children)) if (blockTags.has(child.tagName.toLowerCase())) convertBlockElement(child, fragment);
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
//#endregion
|
|
1855
|
+
//#region packages/convert/src/diff.ts
|
|
1856
|
+
function attrsToJson(el) {
|
|
1857
|
+
const out = {};
|
|
1858
|
+
let count = 0;
|
|
1859
|
+
for (const key of Object.keys(el.getAttributes())) {
|
|
1860
|
+
out[key] = normaliseAttr(el.getAttribute(key));
|
|
1861
|
+
count++;
|
|
1862
|
+
}
|
|
1863
|
+
if (count === 0) return void 0;
|
|
1864
|
+
return sortKeys(out);
|
|
1865
|
+
}
|
|
1866
|
+
function normaliseAttr(v) {
|
|
1867
|
+
if (v == null) return null;
|
|
1868
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
1869
|
+
if (Array.isArray(v)) return v.map(normaliseAttr);
|
|
1870
|
+
if (typeof v === "object") {
|
|
1871
|
+
const out = {};
|
|
1872
|
+
for (const k of Object.keys(v).sort()) out[k] = normaliseAttr(v[k]);
|
|
1873
|
+
return out;
|
|
1874
|
+
}
|
|
1875
|
+
return null;
|
|
1876
|
+
}
|
|
1877
|
+
function sortKeys(obj) {
|
|
1878
|
+
const out = {};
|
|
1879
|
+
for (const k of Object.keys(obj).sort()) out[k] = obj[k];
|
|
1880
|
+
return out;
|
|
1881
|
+
}
|
|
1882
|
+
function textToJson(text) {
|
|
1883
|
+
const delta = text.toDelta();
|
|
1884
|
+
const runs = [];
|
|
1885
|
+
for (const op of delta) {
|
|
1886
|
+
if (typeof op.insert !== "string") continue;
|
|
1887
|
+
const run = { insert: op.insert };
|
|
1888
|
+
if (op.attributes && Object.keys(op.attributes).length > 0) {
|
|
1889
|
+
const normalised = {};
|
|
1890
|
+
for (const k of Object.keys(op.attributes).sort()) normalised[k] = normaliseAttr(op.attributes[k]);
|
|
1891
|
+
run.attributes = normalised;
|
|
1892
|
+
}
|
|
1893
|
+
runs.push(run);
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
kind: "text",
|
|
1897
|
+
runs
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
function elementToJson(el) {
|
|
1901
|
+
const attrs = attrsToJson(el);
|
|
1902
|
+
const children = [];
|
|
1903
|
+
for (const child of el.toArray()) if (child instanceof Y.XmlElement) children.push(elementToJson(child));
|
|
1904
|
+
else if (child instanceof Y.XmlText) children.push(textToJson(child));
|
|
1905
|
+
const out = {
|
|
1906
|
+
kind: "element",
|
|
1907
|
+
tag: el.nodeName,
|
|
1908
|
+
children
|
|
1909
|
+
};
|
|
1910
|
+
if (attrs) out.attrs = attrs;
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
function yfragmentToJson(frag) {
|
|
1914
|
+
const children = [];
|
|
1915
|
+
for (const child of frag.toArray()) if (child instanceof Y.XmlElement) children.push(elementToJson(child));
|
|
1916
|
+
else if (child instanceof Y.XmlText) children.push(textToJson(child));
|
|
1917
|
+
return {
|
|
1918
|
+
kind: "element",
|
|
1919
|
+
tag: "#fragment",
|
|
1920
|
+
children
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
function populateElement(el, node) {
|
|
1924
|
+
if (node.attrs) for (const k of Object.keys(node.attrs)) {
|
|
1925
|
+
const v = node.attrs[k];
|
|
1926
|
+
el.setAttribute(k, v);
|
|
1927
|
+
}
|
|
1928
|
+
for (const child of node.children) if (child.kind === "element") {
|
|
1929
|
+
const sub = new Y.XmlElement(child.tag);
|
|
1930
|
+
el.insert(el.length, [sub]);
|
|
1931
|
+
populateElement(sub, child);
|
|
1932
|
+
} else {
|
|
1933
|
+
const text = new Y.XmlText();
|
|
1934
|
+
el.insert(el.length, [text]);
|
|
1935
|
+
populateText(text, child);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
function populateText(text, node) {
|
|
1939
|
+
let offset = 0;
|
|
1940
|
+
for (const run of node.runs) {
|
|
1941
|
+
if (run.attributes) text.insert(offset, run.insert, run.attributes);
|
|
1942
|
+
else text.insert(offset, run.insert);
|
|
1943
|
+
offset += run.insert.length;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Build a Y.XmlFragment from a previously captured golden JSON. The
|
|
1948
|
+
* returned fragment is bound to a fresh Y.Doc so consumers can hand
|
|
1949
|
+
* it to serialisers immediately. All Yjs mutations happen inside a
|
|
1950
|
+
* single transaction.
|
|
1951
|
+
*/
|
|
1952
|
+
function jsonToYFragment(root, doc, key = "body") {
|
|
1953
|
+
if (root.tag !== "#fragment") throw new Error(`jsonToYFragment expected tag="#fragment", got "${root.tag}"`);
|
|
1954
|
+
const ydoc = doc ?? new Y.Doc();
|
|
1955
|
+
const frag = ydoc.getXmlFragment(key);
|
|
1956
|
+
ydoc.transact(() => {
|
|
1957
|
+
for (const child of root.children) if (child.kind === "element") {
|
|
1958
|
+
const sub = new Y.XmlElement(child.tag);
|
|
1959
|
+
frag.insert(frag.length, [sub]);
|
|
1960
|
+
populateElement(sub, child);
|
|
1961
|
+
} else {
|
|
1962
|
+
const text = new Y.XmlText();
|
|
1963
|
+
frag.insert(frag.length, [text]);
|
|
1964
|
+
populateText(text, child);
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
return frag;
|
|
1968
|
+
}
|
|
1969
|
+
function diffYjs(a, b) {
|
|
1970
|
+
return diffNode(yfragmentToJson(a), yfragmentToJson(b), "");
|
|
1971
|
+
}
|
|
1972
|
+
function diffJson(a, b) {
|
|
1973
|
+
return diffNode(a, b, "");
|
|
1974
|
+
}
|
|
1975
|
+
function diffNode(a, b, path) {
|
|
1976
|
+
if (a.kind !== b.kind) return {
|
|
1977
|
+
equal: false,
|
|
1978
|
+
path,
|
|
1979
|
+
reason: `kind mismatch: ${a.kind} vs ${b.kind}`
|
|
1980
|
+
};
|
|
1981
|
+
if (a.kind === "text" && b.kind === "text") {
|
|
1982
|
+
const ar = a.runs;
|
|
1983
|
+
const br = b.runs;
|
|
1984
|
+
if (ar.length !== br.length) return {
|
|
1985
|
+
equal: false,
|
|
1986
|
+
path,
|
|
1987
|
+
reason: `text run count: ${ar.length} vs ${br.length}`
|
|
1988
|
+
};
|
|
1989
|
+
for (let i = 0; i < ar.length; i++) {
|
|
1990
|
+
const sub = `${path}/runs[${i}]`;
|
|
1991
|
+
const ai = ar[i];
|
|
1992
|
+
const bi = br[i];
|
|
1993
|
+
if (ai.insert !== bi.insert) return {
|
|
1994
|
+
equal: false,
|
|
1995
|
+
path: sub,
|
|
1996
|
+
reason: `insert: ${JSON.stringify(ai.insert)} vs ${JSON.stringify(bi.insert)}`
|
|
1997
|
+
};
|
|
1998
|
+
const attrDiff = diffAttrs(ai.attributes, bi.attributes, sub);
|
|
1999
|
+
if (!attrDiff.equal) return attrDiff;
|
|
2000
|
+
}
|
|
2001
|
+
return {
|
|
2002
|
+
equal: true,
|
|
2003
|
+
path: "",
|
|
2004
|
+
reason: ""
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
if (a.kind === "element" && b.kind === "element") {
|
|
2008
|
+
if (a.tag !== b.tag) return {
|
|
2009
|
+
equal: false,
|
|
2010
|
+
path,
|
|
2011
|
+
reason: `tag: ${a.tag} vs ${b.tag}`
|
|
2012
|
+
};
|
|
2013
|
+
const attrDiff = diffAttrs(a.attrs, b.attrs, path);
|
|
2014
|
+
if (!attrDiff.equal) return attrDiff;
|
|
2015
|
+
if (a.children.length !== b.children.length) return {
|
|
2016
|
+
equal: false,
|
|
2017
|
+
path,
|
|
2018
|
+
reason: `child count: ${a.children.length} vs ${b.children.length}`
|
|
2019
|
+
};
|
|
2020
|
+
for (let i = 0; i < a.children.length; i++) {
|
|
2021
|
+
const sub = path === "" ? `[${i}]` : `${path}[${i}]`;
|
|
2022
|
+
const childDiff = diffNode(a.children[i], b.children[i], sub);
|
|
2023
|
+
if (!childDiff.equal) return childDiff;
|
|
2024
|
+
}
|
|
2025
|
+
return {
|
|
2026
|
+
equal: true,
|
|
2027
|
+
path: "",
|
|
2028
|
+
reason: ""
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
return {
|
|
2032
|
+
equal: false,
|
|
2033
|
+
path,
|
|
2034
|
+
reason: "unreachable kind combination"
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
function diffAttrs(a, b, path) {
|
|
2038
|
+
const ak = a ? Object.keys(a).sort() : [];
|
|
2039
|
+
const bk = b ? Object.keys(b).sort() : [];
|
|
2040
|
+
if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return {
|
|
2041
|
+
equal: false,
|
|
2042
|
+
path: `${path}.attrs`,
|
|
2043
|
+
reason: `attr keys: [${ak.join(",")}] vs [${bk.join(",")}]`
|
|
2044
|
+
};
|
|
2045
|
+
for (const k of ak) {
|
|
2046
|
+
const av = a[k];
|
|
2047
|
+
const bv = b[k];
|
|
2048
|
+
if (!attrEqual(av, bv)) return {
|
|
2049
|
+
equal: false,
|
|
2050
|
+
path: `${path}.attrs.${k}`,
|
|
2051
|
+
reason: `attr value: ${JSON.stringify(av)} vs ${JSON.stringify(bv)}`
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
return {
|
|
2055
|
+
equal: true,
|
|
2056
|
+
path: "",
|
|
2057
|
+
reason: ""
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
function attrEqual(a, b) {
|
|
2061
|
+
if (a === b) return true;
|
|
2062
|
+
if (a === null || b === null) return false;
|
|
2063
|
+
if (typeof a !== typeof b) return false;
|
|
2064
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
2065
|
+
if (a.length !== b.length) return false;
|
|
2066
|
+
return a.every((v, i) => attrEqual(v, b[i]));
|
|
2067
|
+
}
|
|
2068
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
2069
|
+
const ak = Object.keys(a).sort();
|
|
2070
|
+
const bk = Object.keys(b).sort();
|
|
2071
|
+
if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false;
|
|
2072
|
+
return ak.every((k) => attrEqual(a[k], b[k]));
|
|
2073
|
+
}
|
|
2074
|
+
return false;
|
|
2075
|
+
}
|
|
2076
|
+
/** Stringify a YjsJsonNode with sorted keys for byte-stable golden files. */
|
|
2077
|
+
function stringifyJsonNode(node) {
|
|
2078
|
+
return JSON.stringify(node, null, 2) + "\n";
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
//#endregion
|
|
2082
|
+
//#region packages/convert/src/spec/nodes.ts
|
|
2083
|
+
const BOOL_FALSE_DEFAULT = {
|
|
2084
|
+
key: "",
|
|
2085
|
+
type: "boolean",
|
|
2086
|
+
default: false,
|
|
2087
|
+
optional: true
|
|
2088
|
+
};
|
|
2089
|
+
const bool = (key) => ({
|
|
2090
|
+
...BOOL_FALSE_DEFAULT,
|
|
2091
|
+
key
|
|
2092
|
+
});
|
|
2093
|
+
const str = (key, def) => ({
|
|
2094
|
+
key,
|
|
2095
|
+
type: "string",
|
|
2096
|
+
default: def,
|
|
2097
|
+
optional: true
|
|
2098
|
+
});
|
|
2099
|
+
const num = (key) => ({
|
|
2100
|
+
key,
|
|
2101
|
+
type: "number",
|
|
2102
|
+
optional: true
|
|
2103
|
+
});
|
|
2104
|
+
const int = (key) => ({
|
|
2105
|
+
key,
|
|
2106
|
+
type: "integer",
|
|
2107
|
+
optional: true
|
|
2108
|
+
});
|
|
2109
|
+
const VANILLA_BLOCKS = [
|
|
2110
|
+
{
|
|
2111
|
+
name: "documentHeader",
|
|
2112
|
+
group: "block",
|
|
2113
|
+
wire: "special",
|
|
2114
|
+
doc: "Holds the title; hoisted to frontmatter on serialise."
|
|
2115
|
+
},
|
|
2116
|
+
{
|
|
2117
|
+
name: "documentMeta",
|
|
2118
|
+
group: "block",
|
|
2119
|
+
wire: "special",
|
|
2120
|
+
doc: "Holds page-level meta; serialised into frontmatter."
|
|
2121
|
+
},
|
|
2122
|
+
{
|
|
2123
|
+
name: "paragraph",
|
|
2124
|
+
group: "block",
|
|
2125
|
+
wire: "vanilla",
|
|
2126
|
+
contentBearing: true
|
|
2127
|
+
},
|
|
2128
|
+
{
|
|
2129
|
+
name: "heading",
|
|
2130
|
+
group: "block",
|
|
2131
|
+
wire: "vanilla",
|
|
2132
|
+
attrs: [int("level")],
|
|
2133
|
+
contentBearing: true
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
name: "blockquote",
|
|
2137
|
+
group: "block",
|
|
2138
|
+
wire: "vanilla",
|
|
2139
|
+
contentBearing: true
|
|
2140
|
+
},
|
|
2141
|
+
{
|
|
2142
|
+
name: "codeBlock",
|
|
2143
|
+
group: "block",
|
|
2144
|
+
wire: "fence",
|
|
2145
|
+
attrs: [str("language", "")]
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
name: "bulletList",
|
|
2149
|
+
group: "block",
|
|
2150
|
+
wire: "vanilla"
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
name: "orderedList",
|
|
2154
|
+
group: "block",
|
|
2155
|
+
wire: "vanilla"
|
|
2156
|
+
},
|
|
2157
|
+
{
|
|
2158
|
+
name: "listItem",
|
|
2159
|
+
group: "block",
|
|
2160
|
+
wire: "vanilla",
|
|
2161
|
+
contentBearing: true
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
name: "taskList",
|
|
2165
|
+
group: "block",
|
|
2166
|
+
wire: "vanilla"
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
name: "taskItem",
|
|
2170
|
+
group: "block",
|
|
2171
|
+
wire: "vanilla",
|
|
2172
|
+
attrs: [bool("checked")],
|
|
2173
|
+
contentBearing: true
|
|
2174
|
+
},
|
|
2175
|
+
{
|
|
2176
|
+
name: "table",
|
|
2177
|
+
group: "block",
|
|
2178
|
+
wire: "vanilla"
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
name: "tableRow",
|
|
2182
|
+
group: "block",
|
|
2183
|
+
wire: "vanilla"
|
|
2184
|
+
},
|
|
2185
|
+
{
|
|
2186
|
+
name: "tableHeader",
|
|
2187
|
+
group: "block",
|
|
2188
|
+
wire: "vanilla",
|
|
2189
|
+
contentBearing: true
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
name: "tableCell",
|
|
2193
|
+
group: "block",
|
|
2194
|
+
wire: "vanilla",
|
|
2195
|
+
contentBearing: true
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
name: "horizontalRule",
|
|
2199
|
+
group: "block",
|
|
2200
|
+
wire: "vanilla"
|
|
2201
|
+
},
|
|
2202
|
+
{
|
|
2203
|
+
name: "image",
|
|
2204
|
+
group: "block",
|
|
2205
|
+
wire: "special",
|
|
2206
|
+
attrs: [
|
|
2207
|
+
str("src"),
|
|
2208
|
+
str("alt", ""),
|
|
2209
|
+
int("width"),
|
|
2210
|
+
int("height")
|
|
2211
|
+
]
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
name: "hardBreak",
|
|
2215
|
+
group: "inline",
|
|
2216
|
+
wire: "vanilla"
|
|
2217
|
+
}
|
|
2218
|
+
];
|
|
2219
|
+
const MDC_CONTAINERS = [
|
|
2220
|
+
{
|
|
2221
|
+
name: "callout",
|
|
2222
|
+
group: "block",
|
|
2223
|
+
wire: "mdc-container",
|
|
2224
|
+
attrs: [
|
|
2225
|
+
{
|
|
2226
|
+
key: "type",
|
|
2227
|
+
type: "string",
|
|
2228
|
+
default: "note",
|
|
2229
|
+
optional: true,
|
|
2230
|
+
values: [
|
|
2231
|
+
"note",
|
|
2232
|
+
"tip",
|
|
2233
|
+
"warning",
|
|
2234
|
+
"danger",
|
|
2235
|
+
"info",
|
|
2236
|
+
"caution",
|
|
2237
|
+
"alert",
|
|
2238
|
+
"success",
|
|
2239
|
+
"error"
|
|
2240
|
+
]
|
|
2241
|
+
},
|
|
2242
|
+
str("title"),
|
|
2243
|
+
str("icon")
|
|
2244
|
+
]
|
|
2245
|
+
},
|
|
2246
|
+
{
|
|
2247
|
+
name: "collapsible",
|
|
2248
|
+
group: "block",
|
|
2249
|
+
wire: "mdc-container",
|
|
2250
|
+
attrs: [str("label", "Details"), bool("open")]
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
name: "accordion",
|
|
2254
|
+
group: "block",
|
|
2255
|
+
wire: "mdc-slotted",
|
|
2256
|
+
slotChild: "accordionItem"
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
name: "accordionItem",
|
|
2260
|
+
group: "block",
|
|
2261
|
+
wire: "mdc-container",
|
|
2262
|
+
mdcTag: "accordion-item",
|
|
2263
|
+
attrs: [str("label", "Item"), str("icon")]
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
name: "tabs",
|
|
2267
|
+
group: "block",
|
|
2268
|
+
wire: "mdc-slotted",
|
|
2269
|
+
slotChild: "tabsItem"
|
|
2270
|
+
},
|
|
2271
|
+
{
|
|
2272
|
+
name: "tabsItem",
|
|
2273
|
+
group: "block",
|
|
2274
|
+
wire: "mdc-container",
|
|
2275
|
+
mdcTag: "tabs-item",
|
|
2276
|
+
attrs: [str("label"), str("icon")]
|
|
2277
|
+
},
|
|
2278
|
+
{
|
|
2279
|
+
name: "steps",
|
|
2280
|
+
group: "block",
|
|
2281
|
+
wire: "mdc-container"
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
name: "card",
|
|
2285
|
+
group: "block",
|
|
2286
|
+
wire: "mdc-container",
|
|
2287
|
+
attrs: [
|
|
2288
|
+
str("title"),
|
|
2289
|
+
str("icon"),
|
|
2290
|
+
str("to")
|
|
2291
|
+
]
|
|
2292
|
+
},
|
|
2293
|
+
{
|
|
2294
|
+
name: "cardGroup",
|
|
2295
|
+
group: "block",
|
|
2296
|
+
wire: "mdc-slotted",
|
|
2297
|
+
mdcTag: "card-group",
|
|
2298
|
+
slotChild: "card"
|
|
2299
|
+
},
|
|
2300
|
+
{
|
|
2301
|
+
name: "field",
|
|
2302
|
+
group: "block",
|
|
2303
|
+
wire: "mdc-container",
|
|
2304
|
+
attrs: [
|
|
2305
|
+
str("name"),
|
|
2306
|
+
str("type", "string"),
|
|
2307
|
+
bool("required")
|
|
2308
|
+
]
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
name: "fieldGroup",
|
|
2312
|
+
group: "block",
|
|
2313
|
+
wire: "mdc-slotted",
|
|
2314
|
+
mdcTag: "field-group",
|
|
2315
|
+
slotChild: "field"
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
name: "codeGroup",
|
|
2319
|
+
group: "block",
|
|
2320
|
+
wire: "mdc-slotted",
|
|
2321
|
+
mdcTag: "code-group",
|
|
2322
|
+
slotChild: "codeBlock"
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
name: "codeCollapse",
|
|
2326
|
+
group: "block",
|
|
2327
|
+
wire: "mdc-container",
|
|
2328
|
+
mdcTag: "code-collapse"
|
|
2329
|
+
},
|
|
2330
|
+
{
|
|
2331
|
+
name: "codePreview",
|
|
2332
|
+
group: "block",
|
|
2333
|
+
wire: "mdc-container",
|
|
2334
|
+
mdcTag: "code-preview"
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
name: "codeTree",
|
|
2338
|
+
group: "block",
|
|
2339
|
+
wire: "mdc-atom-block",
|
|
2340
|
+
mdcTag: "code-tree",
|
|
2341
|
+
attrs: [{
|
|
2342
|
+
key: "files",
|
|
2343
|
+
type: "json"
|
|
2344
|
+
}]
|
|
2345
|
+
},
|
|
2346
|
+
{
|
|
2347
|
+
name: "figure",
|
|
2348
|
+
group: "block",
|
|
2349
|
+
wire: "mdc-container",
|
|
2350
|
+
attrs: [
|
|
2351
|
+
str("src"),
|
|
2352
|
+
str("alt", ""),
|
|
2353
|
+
str("caption")
|
|
2354
|
+
]
|
|
2355
|
+
},
|
|
2356
|
+
{
|
|
2357
|
+
name: "video",
|
|
2358
|
+
group: "block",
|
|
2359
|
+
wire: "mdc-atom-block",
|
|
2360
|
+
attrs: [
|
|
2361
|
+
str("src"),
|
|
2362
|
+
str("poster"),
|
|
2363
|
+
bool("autoplay"),
|
|
2364
|
+
bool("loop"),
|
|
2365
|
+
bool("controls")
|
|
2366
|
+
]
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
name: "embed",
|
|
2370
|
+
group: "block",
|
|
2371
|
+
wire: "mdc-atom-block",
|
|
2372
|
+
attrs: [str("src"), str("title")]
|
|
2373
|
+
},
|
|
2374
|
+
{
|
|
2375
|
+
name: "svgEmbed",
|
|
2376
|
+
group: "block",
|
|
2377
|
+
wire: "fence",
|
|
2378
|
+
attrs: [str("title")],
|
|
2379
|
+
mdcTag: "svg",
|
|
2380
|
+
doc: "Serialised as a ```svg fenced block; the SVG markup is the body."
|
|
2381
|
+
},
|
|
2382
|
+
{
|
|
2383
|
+
name: "divider",
|
|
2384
|
+
group: "block",
|
|
2385
|
+
wire: "mdc-atom-block",
|
|
2386
|
+
attrs: [str("label"), str("icon")]
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
name: "quote",
|
|
2390
|
+
group: "block",
|
|
2391
|
+
wire: "mdc-container",
|
|
2392
|
+
attrs: [str("cite")]
|
|
2393
|
+
},
|
|
2394
|
+
{
|
|
2395
|
+
name: "progress",
|
|
2396
|
+
group: "block",
|
|
2397
|
+
wire: "mdc-atom-block",
|
|
2398
|
+
attrs: [
|
|
2399
|
+
num("value"),
|
|
2400
|
+
num("max"),
|
|
2401
|
+
str("label")
|
|
2402
|
+
]
|
|
2403
|
+
},
|
|
2404
|
+
{
|
|
2405
|
+
name: "spoiler",
|
|
2406
|
+
group: "block",
|
|
2407
|
+
wire: "mdc-container",
|
|
2408
|
+
attrs: [str("label")]
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
name: "colorSwatch",
|
|
2412
|
+
group: "block",
|
|
2413
|
+
wire: "mdc-atom-block",
|
|
2414
|
+
mdcTag: "color-swatch",
|
|
2415
|
+
attrs: [str("color"), str("label")]
|
|
2416
|
+
},
|
|
2417
|
+
{
|
|
2418
|
+
name: "stat",
|
|
2419
|
+
group: "block",
|
|
2420
|
+
wire: "mdc-container",
|
|
2421
|
+
attrs: [
|
|
2422
|
+
str("label"),
|
|
2423
|
+
str("value"),
|
|
2424
|
+
str("icon")
|
|
2425
|
+
]
|
|
2426
|
+
},
|
|
2427
|
+
{
|
|
2428
|
+
name: "statGroup",
|
|
2429
|
+
group: "block",
|
|
2430
|
+
wire: "mdc-slotted",
|
|
2431
|
+
mdcTag: "stat-group",
|
|
2432
|
+
slotChild: "stat"
|
|
2433
|
+
},
|
|
2434
|
+
{
|
|
2435
|
+
name: "button",
|
|
2436
|
+
group: "block",
|
|
2437
|
+
wire: "mdc-atom-block",
|
|
2438
|
+
attrs: [
|
|
2439
|
+
str("label"),
|
|
2440
|
+
str("to"),
|
|
2441
|
+
str("icon"),
|
|
2442
|
+
str("variant")
|
|
2443
|
+
]
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
name: "buttonGroup",
|
|
2447
|
+
group: "block",
|
|
2448
|
+
wire: "mdc-slotted",
|
|
2449
|
+
mdcTag: "button-group",
|
|
2450
|
+
slotChild: "button"
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
name: "timeline",
|
|
2454
|
+
group: "block",
|
|
2455
|
+
wire: "mdc-slotted",
|
|
2456
|
+
slotChild: "timelineItem"
|
|
2457
|
+
},
|
|
2458
|
+
{
|
|
2459
|
+
name: "timelineItem",
|
|
2460
|
+
group: "block",
|
|
2461
|
+
wire: "mdc-container",
|
|
2462
|
+
mdcTag: "timeline-item",
|
|
2463
|
+
attrs: [
|
|
2464
|
+
str("label"),
|
|
2465
|
+
str("icon"),
|
|
2466
|
+
str("date")
|
|
2467
|
+
]
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
name: "diff",
|
|
2471
|
+
group: "block",
|
|
2472
|
+
wire: "mdc-atom-block",
|
|
2473
|
+
attrs: [str("language", ""), {
|
|
2474
|
+
key: "value",
|
|
2475
|
+
type: "string"
|
|
2476
|
+
}]
|
|
2477
|
+
}
|
|
2478
|
+
];
|
|
2479
|
+
const INLINE_AND_SPECIAL = [
|
|
2480
|
+
{
|
|
2481
|
+
name: "docLink",
|
|
2482
|
+
group: "inline",
|
|
2483
|
+
wire: "special",
|
|
2484
|
+
attrs: [str("docId")],
|
|
2485
|
+
doc: "Wire form `[[uuid|label]]`; label regenerated on export."
|
|
2486
|
+
},
|
|
2487
|
+
{
|
|
2488
|
+
name: "docEmbed",
|
|
2489
|
+
group: "block",
|
|
2490
|
+
wire: "special",
|
|
2491
|
+
attrs: [
|
|
2492
|
+
str("docId"),
|
|
2493
|
+
bool("collapsed"),
|
|
2494
|
+
bool("tall"),
|
|
2495
|
+
bool("seamless")
|
|
2496
|
+
],
|
|
2497
|
+
doc: "Wire form `![[uuid|label]]{collapsed tall seamless}`."
|
|
2498
|
+
},
|
|
2499
|
+
{
|
|
2500
|
+
name: "mention",
|
|
2501
|
+
group: "inline",
|
|
2502
|
+
wire: "special",
|
|
2503
|
+
attrs: [str("userId")],
|
|
2504
|
+
doc: "Wire form `@[label](user:uuid)`; label regenerated on export."
|
|
2505
|
+
},
|
|
2506
|
+
{
|
|
2507
|
+
name: "mathInline",
|
|
2508
|
+
group: "inline",
|
|
2509
|
+
wire: "special",
|
|
2510
|
+
attrs: [{
|
|
2511
|
+
key: "expression",
|
|
2512
|
+
type: "string"
|
|
2513
|
+
}],
|
|
2514
|
+
doc: "Wire form `$expression$`."
|
|
2515
|
+
},
|
|
2516
|
+
{
|
|
2517
|
+
name: "mathBlock",
|
|
2518
|
+
group: "block",
|
|
2519
|
+
wire: "fence",
|
|
2520
|
+
attrs: [{
|
|
2521
|
+
key: "expression",
|
|
2522
|
+
type: "string"
|
|
2523
|
+
}],
|
|
2524
|
+
mdcTag: "math",
|
|
2525
|
+
doc: "Wire form ``` ```math\\nexpression\\n``` ```."
|
|
2526
|
+
},
|
|
2527
|
+
{
|
|
2528
|
+
name: "fileBlock",
|
|
2529
|
+
group: "block",
|
|
2530
|
+
wire: "mdc-atom-block",
|
|
2531
|
+
mdcTag: "file",
|
|
2532
|
+
attrs: [
|
|
2533
|
+
str("src"),
|
|
2534
|
+
str("mime"),
|
|
2535
|
+
str("uploadId"),
|
|
2536
|
+
str("filename")
|
|
2537
|
+
],
|
|
2538
|
+
doc: "Wire form `:file{src=… mime=… upload-id=… filename=…}`; binary in sidecar."
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
name: "badge",
|
|
2542
|
+
group: "inline",
|
|
2543
|
+
wire: "mdc-atom-inl",
|
|
2544
|
+
attrs: [
|
|
2545
|
+
str("label"),
|
|
2546
|
+
str("color"),
|
|
2547
|
+
str("variant", "subtle")
|
|
2548
|
+
],
|
|
2549
|
+
doc: "Wire form `:badge[Label]{color=… variant=…}`."
|
|
2550
|
+
},
|
|
2551
|
+
{
|
|
2552
|
+
name: "proseIcon",
|
|
2553
|
+
group: "inline",
|
|
2554
|
+
wire: "mdc-atom-inl",
|
|
2555
|
+
mdcTag: "icon",
|
|
2556
|
+
attrs: [str("name")],
|
|
2557
|
+
doc: "Wire form `:icon{name=…}`."
|
|
2558
|
+
},
|
|
2559
|
+
{
|
|
2560
|
+
name: "kbd",
|
|
2561
|
+
group: "inline",
|
|
2562
|
+
wire: "mdc-atom-inl",
|
|
2563
|
+
attrs: [str("value")],
|
|
2564
|
+
doc: "Wire form `:kbd{value=…}`."
|
|
2565
|
+
}
|
|
2566
|
+
];
|
|
2567
|
+
const NODE_SPECS = [
|
|
2568
|
+
...VANILLA_BLOCKS,
|
|
2569
|
+
...MDC_CONTAINERS,
|
|
2570
|
+
...INLINE_AND_SPECIAL
|
|
2571
|
+
];
|
|
2572
|
+
const NODE_SPEC_BY_NAME = new Map(NODE_SPECS.map((spec) => [spec.name, spec]));
|
|
2573
|
+
/** Derive the MDC tag for a node — `mdcTag` override or kebab-case of `name`. */
|
|
2574
|
+
function mdcTagOf(spec) {
|
|
2575
|
+
if (spec.mdcTag) return spec.mdcTag;
|
|
2576
|
+
return spec.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
//#endregion
|
|
2580
|
+
//#region packages/convert/src/spec/marks.ts
|
|
2581
|
+
const MARK_SPECS = [
|
|
2582
|
+
{
|
|
2583
|
+
name: "bold",
|
|
2584
|
+
wire: "delimited",
|
|
2585
|
+
delim: "**"
|
|
2586
|
+
},
|
|
2587
|
+
{
|
|
2588
|
+
name: "italic",
|
|
2589
|
+
wire: "delimited",
|
|
2590
|
+
delim: "*"
|
|
2591
|
+
},
|
|
2592
|
+
{
|
|
2593
|
+
name: "strike",
|
|
2594
|
+
wire: "delimited",
|
|
2595
|
+
delim: "~~"
|
|
2596
|
+
},
|
|
2597
|
+
{
|
|
2598
|
+
name: "code",
|
|
2599
|
+
wire: "delimited",
|
|
2600
|
+
delim: "`"
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
name: "link",
|
|
2604
|
+
wire: "link",
|
|
2605
|
+
attrs: [{
|
|
2606
|
+
key: "href",
|
|
2607
|
+
type: "string"
|
|
2608
|
+
}, {
|
|
2609
|
+
key: "title",
|
|
2610
|
+
type: "string",
|
|
2611
|
+
optional: true
|
|
2612
|
+
}]
|
|
2613
|
+
},
|
|
2614
|
+
{
|
|
2615
|
+
name: "underline",
|
|
2616
|
+
wire: "delimited",
|
|
2617
|
+
delim: "__",
|
|
2618
|
+
doc: "Disambiguated from bold by delimiter character. Two underscores = underline; two asterisks = bold."
|
|
2619
|
+
},
|
|
2620
|
+
{
|
|
2621
|
+
name: "highlight",
|
|
2622
|
+
wire: "delimited",
|
|
2623
|
+
delim: "==",
|
|
2624
|
+
doc: "Pandoc-style."
|
|
2625
|
+
},
|
|
2626
|
+
{
|
|
2627
|
+
name: "subscript",
|
|
2628
|
+
wire: "delimited",
|
|
2629
|
+
delim: "~",
|
|
2630
|
+
doc: "Single tilde; double tilde is strike."
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
name: "superscript",
|
|
2634
|
+
wire: "delimited",
|
|
2635
|
+
delim: "^"
|
|
2636
|
+
},
|
|
2637
|
+
{
|
|
2638
|
+
name: "textStyle",
|
|
2639
|
+
wire: "mdc-span",
|
|
2640
|
+
attrs: [
|
|
2641
|
+
{
|
|
2642
|
+
key: "color",
|
|
2643
|
+
type: "string",
|
|
2644
|
+
optional: true
|
|
2645
|
+
},
|
|
2646
|
+
{
|
|
2647
|
+
key: "backgroundColor",
|
|
2648
|
+
type: "string",
|
|
2649
|
+
optional: true
|
|
2650
|
+
},
|
|
2651
|
+
{
|
|
2652
|
+
key: "fontSize",
|
|
2653
|
+
type: "string",
|
|
2654
|
+
optional: true
|
|
2655
|
+
},
|
|
2656
|
+
{
|
|
2657
|
+
key: "fontFamily",
|
|
2658
|
+
type: "string",
|
|
2659
|
+
optional: true
|
|
2660
|
+
}
|
|
2661
|
+
],
|
|
2662
|
+
doc: "Wire form `:span[text]{color=\"…\" font-size=\"…\"}`. Any of the attrs may be set."
|
|
2663
|
+
}
|
|
2664
|
+
];
|
|
2665
|
+
const MARK_SPEC_BY_NAME = new Map(MARK_SPECS.map((spec) => [spec.name, spec]));
|
|
2666
|
+
const MARK_SPEC_BY_DELIM = new Map(MARK_SPECS.filter((spec) => spec.wire === "delimited" && !!spec.delim).map((spec) => [spec.delim, spec]));
|
|
2667
|
+
|
|
2668
|
+
//#endregion
|
|
2669
|
+
//#region packages/convert/src/spec/universal-meta.ts
|
|
2670
|
+
const UNIVERSAL_META_KEYS = [
|
|
2671
|
+
{
|
|
2672
|
+
key: "title",
|
|
2673
|
+
type: "string",
|
|
2674
|
+
doc: "Display title; the first H1 is hoisted into this field on import."
|
|
2675
|
+
},
|
|
2676
|
+
{
|
|
2677
|
+
key: "type",
|
|
2678
|
+
type: "string",
|
|
2679
|
+
doc: "Page type (doc, kanban, table, …). Omitted on serialise when \"doc\"."
|
|
2680
|
+
},
|
|
2681
|
+
{
|
|
2682
|
+
key: "color",
|
|
2683
|
+
type: "string",
|
|
2684
|
+
doc: "Hex or CSS color name."
|
|
2685
|
+
},
|
|
2686
|
+
{
|
|
2687
|
+
key: "icon",
|
|
2688
|
+
type: "string",
|
|
2689
|
+
doc: "Lucide icon name in kebab-case."
|
|
2690
|
+
},
|
|
2691
|
+
{
|
|
2692
|
+
key: "datetimeStart",
|
|
2693
|
+
type: "iso-datetime"
|
|
2694
|
+
},
|
|
2695
|
+
{
|
|
2696
|
+
key: "datetimeEnd",
|
|
2697
|
+
type: "iso-datetime"
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
key: "allDay",
|
|
2701
|
+
type: "boolean"
|
|
2702
|
+
},
|
|
2703
|
+
{
|
|
2704
|
+
key: "dateTaken",
|
|
2705
|
+
type: "iso-datetime"
|
|
2706
|
+
},
|
|
2707
|
+
{
|
|
2708
|
+
key: "dateStart",
|
|
2709
|
+
type: "iso-date",
|
|
2710
|
+
parseAliases: ["date", "created"]
|
|
2711
|
+
},
|
|
2712
|
+
{
|
|
2713
|
+
key: "dateEnd",
|
|
2714
|
+
type: "iso-date",
|
|
2715
|
+
parseAliases: ["due"]
|
|
2716
|
+
},
|
|
2717
|
+
{
|
|
2718
|
+
key: "timeStart",
|
|
2719
|
+
type: "hh-mm"
|
|
2720
|
+
},
|
|
2721
|
+
{
|
|
2722
|
+
key: "timeEnd",
|
|
2723
|
+
type: "hh-mm"
|
|
2724
|
+
},
|
|
2725
|
+
{
|
|
2726
|
+
key: "tags",
|
|
2727
|
+
type: "string[]"
|
|
2728
|
+
},
|
|
2729
|
+
{
|
|
2730
|
+
key: "checked",
|
|
2731
|
+
type: "boolean",
|
|
2732
|
+
parseAliases: ["done"]
|
|
2733
|
+
},
|
|
2734
|
+
{
|
|
2735
|
+
key: "priority",
|
|
2736
|
+
type: "integer",
|
|
2737
|
+
min: 0,
|
|
2738
|
+
max: 4,
|
|
2739
|
+
doc: "Numeric or named (low/medium/high/urgent → 1/2/3/4)."
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
key: "status",
|
|
2743
|
+
type: "string"
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
key: "rating",
|
|
2747
|
+
type: "number",
|
|
2748
|
+
min: 0,
|
|
2749
|
+
max: 5
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
key: "url",
|
|
2753
|
+
type: "string"
|
|
2754
|
+
},
|
|
2755
|
+
{
|
|
2756
|
+
key: "email",
|
|
2757
|
+
type: "string"
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
key: "phone",
|
|
2761
|
+
type: "string"
|
|
2762
|
+
},
|
|
2763
|
+
{
|
|
2764
|
+
key: "number",
|
|
2765
|
+
type: "number"
|
|
2766
|
+
},
|
|
2767
|
+
{
|
|
2768
|
+
key: "unit",
|
|
2769
|
+
type: "string"
|
|
2770
|
+
},
|
|
2771
|
+
{
|
|
2772
|
+
key: "subtitle",
|
|
2773
|
+
type: "string",
|
|
2774
|
+
parseAliases: ["description"]
|
|
2775
|
+
},
|
|
2776
|
+
{
|
|
2777
|
+
key: "note",
|
|
2778
|
+
type: "string"
|
|
2779
|
+
},
|
|
2780
|
+
{
|
|
2781
|
+
key: "taskProgress",
|
|
2782
|
+
type: "integer",
|
|
2783
|
+
min: 0,
|
|
2784
|
+
max: 100
|
|
2785
|
+
},
|
|
2786
|
+
{
|
|
2787
|
+
key: "members",
|
|
2788
|
+
type: "members"
|
|
2789
|
+
},
|
|
2790
|
+
{
|
|
2791
|
+
key: "coverUploadId",
|
|
2792
|
+
type: "string"
|
|
2793
|
+
},
|
|
2794
|
+
{
|
|
2795
|
+
key: "coverDocId",
|
|
2796
|
+
type: "string"
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
key: "coverMimeType",
|
|
2800
|
+
type: "string"
|
|
2801
|
+
},
|
|
2802
|
+
{
|
|
2803
|
+
key: "geoType",
|
|
2804
|
+
type: "string-enum",
|
|
2805
|
+
values: [
|
|
2806
|
+
"marker",
|
|
2807
|
+
"line",
|
|
2808
|
+
"measure"
|
|
2809
|
+
]
|
|
2810
|
+
},
|
|
2811
|
+
{
|
|
2812
|
+
key: "geoLat",
|
|
2813
|
+
type: "number"
|
|
2814
|
+
},
|
|
2815
|
+
{
|
|
2816
|
+
key: "geoLng",
|
|
2817
|
+
type: "number"
|
|
2818
|
+
},
|
|
2819
|
+
{
|
|
2820
|
+
key: "geoDescription",
|
|
2821
|
+
type: "string"
|
|
2822
|
+
},
|
|
2823
|
+
{
|
|
2824
|
+
key: "deskX",
|
|
2825
|
+
type: "number"
|
|
2826
|
+
},
|
|
2827
|
+
{
|
|
2828
|
+
key: "deskY",
|
|
2829
|
+
type: "number"
|
|
2830
|
+
},
|
|
2831
|
+
{
|
|
2832
|
+
key: "deskZ",
|
|
2833
|
+
type: "number"
|
|
2834
|
+
},
|
|
2835
|
+
{
|
|
2836
|
+
key: "deskMode",
|
|
2837
|
+
type: "string-enum",
|
|
2838
|
+
values: [
|
|
2839
|
+
"icon",
|
|
2840
|
+
"widget-sm",
|
|
2841
|
+
"widget-lg"
|
|
2842
|
+
]
|
|
2843
|
+
},
|
|
2844
|
+
{
|
|
2845
|
+
key: "mmX",
|
|
2846
|
+
type: "number"
|
|
2847
|
+
},
|
|
2848
|
+
{
|
|
2849
|
+
key: "mmY",
|
|
2850
|
+
type: "number"
|
|
2851
|
+
},
|
|
2852
|
+
{
|
|
2853
|
+
key: "graphX",
|
|
2854
|
+
type: "number"
|
|
2855
|
+
},
|
|
2856
|
+
{
|
|
2857
|
+
key: "graphY",
|
|
2858
|
+
type: "number"
|
|
2859
|
+
},
|
|
2860
|
+
{
|
|
2861
|
+
key: "graphPinned",
|
|
2862
|
+
type: "boolean"
|
|
2863
|
+
},
|
|
2864
|
+
{
|
|
2865
|
+
key: "spX",
|
|
2866
|
+
type: "number"
|
|
2867
|
+
},
|
|
2868
|
+
{
|
|
2869
|
+
key: "spY",
|
|
2870
|
+
type: "number"
|
|
2871
|
+
},
|
|
2872
|
+
{
|
|
2873
|
+
key: "spZ",
|
|
2874
|
+
type: "number"
|
|
2875
|
+
},
|
|
2876
|
+
{
|
|
2877
|
+
key: "spRX",
|
|
2878
|
+
type: "number"
|
|
2879
|
+
},
|
|
2880
|
+
{
|
|
2881
|
+
key: "spRY",
|
|
2882
|
+
type: "number"
|
|
2883
|
+
},
|
|
2884
|
+
{
|
|
2885
|
+
key: "spRZ",
|
|
2886
|
+
type: "number"
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
key: "spSX",
|
|
2890
|
+
type: "number"
|
|
2891
|
+
},
|
|
2892
|
+
{
|
|
2893
|
+
key: "spSY",
|
|
2894
|
+
type: "number"
|
|
2895
|
+
},
|
|
2896
|
+
{
|
|
2897
|
+
key: "spSZ",
|
|
2898
|
+
type: "number"
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
key: "spShape",
|
|
2902
|
+
type: "string-enum",
|
|
2903
|
+
values: [
|
|
2904
|
+
"box",
|
|
2905
|
+
"sphere",
|
|
2906
|
+
"cylinder",
|
|
2907
|
+
"cone",
|
|
2908
|
+
"plane",
|
|
2909
|
+
"torus",
|
|
2910
|
+
"glb"
|
|
2911
|
+
]
|
|
2912
|
+
},
|
|
2913
|
+
{
|
|
2914
|
+
key: "spOpacity",
|
|
2915
|
+
type: "integer",
|
|
2916
|
+
min: 0,
|
|
2917
|
+
max: 100
|
|
2918
|
+
},
|
|
2919
|
+
{
|
|
2920
|
+
key: "spModelUploadId",
|
|
2921
|
+
type: "string"
|
|
2922
|
+
},
|
|
2923
|
+
{
|
|
2924
|
+
key: "spModelDocId",
|
|
2925
|
+
type: "string"
|
|
2926
|
+
},
|
|
2927
|
+
{
|
|
2928
|
+
key: "slidesTransition",
|
|
2929
|
+
type: "string-enum",
|
|
2930
|
+
values: [
|
|
2931
|
+
"none",
|
|
2932
|
+
"fade",
|
|
2933
|
+
"slide"
|
|
2934
|
+
]
|
|
2935
|
+
},
|
|
2936
|
+
{
|
|
2937
|
+
key: "slidesTheme",
|
|
2938
|
+
type: "string-enum",
|
|
2939
|
+
values: ["dark", "light"]
|
|
2940
|
+
},
|
|
2941
|
+
{
|
|
2942
|
+
key: "__schemaVersion",
|
|
2943
|
+
type: "integer",
|
|
2944
|
+
min: 0
|
|
2945
|
+
}
|
|
2946
|
+
];
|
|
2947
|
+
const UNIVERSAL_META_KEY_NAMES = new Set(UNIVERSAL_META_KEYS.map((k) => k.key));
|
|
2948
|
+
/**
|
|
2949
|
+
* Build a map of every recognised input key (canonical + aliases) to
|
|
2950
|
+
* its canonical key. Used by the frontmatter parser.
|
|
2951
|
+
*/
|
|
2952
|
+
function buildAliasMap() {
|
|
2953
|
+
const map = /* @__PURE__ */ new Map();
|
|
2954
|
+
for (const entry of UNIVERSAL_META_KEYS) {
|
|
2955
|
+
map.set(entry.key, entry.key);
|
|
2956
|
+
for (const alias of entry.parseAliases ?? []) map.set(alias, entry.key);
|
|
2957
|
+
}
|
|
2958
|
+
return map;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
//#endregion
|
|
2962
|
+
//#region packages/convert/src/file-blocks/manifest.ts
|
|
2963
|
+
let _adapter = null;
|
|
2964
|
+
/** Install a filesystem adapter. Call this once at boot. */
|
|
2965
|
+
function setFsAdapter(adapter) {
|
|
2966
|
+
_adapter = adapter;
|
|
2967
|
+
}
|
|
2968
|
+
/** Read back the active adapter — useful for tests and for layered code. */
|
|
2969
|
+
function getFsAdapter() {
|
|
2970
|
+
return _adapter;
|
|
2971
|
+
}
|
|
2972
|
+
async function loadFsApi() {
|
|
2973
|
+
if (_adapter) return _adapter;
|
|
2974
|
+
throw new Error("@abraca/convert: no FsAdapter installed. Call setFsAdapter({readTextFile, writeTextFile, mkdir, …}) at boot. Tauri hosts can pass `@tauri-apps/plugin-fs` directly; Node hosts can pass a wrapper over `fs/promises`; tests can use the in-memory adapter from tests/file-blocks.test.ts as a template.");
|
|
2975
|
+
}
|
|
2976
|
+
const MANIFEST_DIR = ".abracadabra";
|
|
2977
|
+
const MANIFEST_FILE = "manifest.json";
|
|
2978
|
+
function manifestPath(syncDir) {
|
|
2979
|
+
return `${syncDir}/${MANIFEST_DIR}/${MANIFEST_FILE}`;
|
|
2980
|
+
}
|
|
2981
|
+
function manifestDir(syncDir) {
|
|
2982
|
+
return `${syncDir}/${MANIFEST_DIR}`;
|
|
2983
|
+
}
|
|
2984
|
+
function trashDir(syncDir) {
|
|
2985
|
+
return `${syncDir}/${MANIFEST_DIR}/trash`;
|
|
2986
|
+
}
|
|
2987
|
+
function orphansDir(syncDir) {
|
|
2988
|
+
return `${syncDir}/${MANIFEST_DIR}/orphans`;
|
|
2989
|
+
}
|
|
2990
|
+
function conflictsDir(syncDir) {
|
|
2991
|
+
return `${syncDir}/${MANIFEST_DIR}/conflicts`;
|
|
2992
|
+
}
|
|
2993
|
+
function createEmptyManifest(spaceId) {
|
|
2994
|
+
return {
|
|
2995
|
+
version: 2,
|
|
2996
|
+
spaceId,
|
|
2997
|
+
lastSyncAt: 0,
|
|
2998
|
+
entries: {}
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
async function loadManifest(syncDir, spaceId) {
|
|
3002
|
+
const fs = await loadFsApi();
|
|
3003
|
+
try {
|
|
3004
|
+
const raw = await fs.readTextFile(manifestPath(syncDir));
|
|
3005
|
+
const parsed = JSON.parse(raw);
|
|
3006
|
+
if (parsed.version === 2) return parsed;
|
|
3007
|
+
} catch {}
|
|
3008
|
+
return createEmptyManifest(spaceId);
|
|
3009
|
+
}
|
|
3010
|
+
async function saveManifest(syncDir, manifest) {
|
|
3011
|
+
const fs = await loadFsApi();
|
|
3012
|
+
const dir = `${syncDir}/${MANIFEST_DIR}`;
|
|
3013
|
+
try {
|
|
3014
|
+
await fs.mkdir(dir, { recursive: true });
|
|
3015
|
+
} catch {}
|
|
3016
|
+
manifest.lastSyncAt = Date.now();
|
|
3017
|
+
await fs.writeTextFile(manifestPath(syncDir), JSON.stringify(manifest, null, 2));
|
|
3018
|
+
}
|
|
3019
|
+
function lookupByDocId(manifest, docId) {
|
|
3020
|
+
return manifest.entries[docId];
|
|
3021
|
+
}
|
|
3022
|
+
function lookupByPath(manifest, relativePath) {
|
|
3023
|
+
for (const entry of Object.values(manifest.entries)) if (entry.relativePath === relativePath) return entry;
|
|
3024
|
+
}
|
|
3025
|
+
function lookupByHash(manifest, contentHash) {
|
|
3026
|
+
for (const entry of Object.values(manifest.entries)) if (entry.contentHash === contentHash) return entry;
|
|
3027
|
+
}
|
|
3028
|
+
function setEntry(manifest, entry) {
|
|
3029
|
+
manifest.entries[entry.docId] = entry;
|
|
3030
|
+
}
|
|
3031
|
+
function removeEntry(manifest, docId) {
|
|
3032
|
+
const entry = manifest.entries[docId];
|
|
3033
|
+
if (entry) delete manifest.entries[docId];
|
|
3034
|
+
return entry;
|
|
3035
|
+
}
|
|
3036
|
+
function buildReverseLookup(manifest) {
|
|
3037
|
+
const map = /* @__PURE__ */ new Map();
|
|
3038
|
+
for (const [docId, entry] of Object.entries(manifest.entries)) map.set(entry.relativePath, docId);
|
|
3039
|
+
return map;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
//#endregion
|
|
3043
|
+
//#region packages/convert/src/file-blocks/paths.ts
|
|
3044
|
+
/**
|
|
3045
|
+
* Convert a document label to a filesystem-safe filename (without extension).
|
|
3046
|
+
* e.g. "My Project!" -> "my-project"
|
|
3047
|
+
*/
|
|
3048
|
+
function labelToFilename(label) {
|
|
3049
|
+
return label.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "untitled";
|
|
3050
|
+
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Convert a filename back to a label (best-effort).
|
|
3053
|
+
* e.g. "my-project" -> "my project", "my-project~a3f2" -> "my project"
|
|
3054
|
+
*/
|
|
3055
|
+
function fsFilenameToLabel(filename) {
|
|
3056
|
+
return filename.replace(/~[a-z0-9]{4}$/, "").replace(/-/g, " ");
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* Check if a doc has children in the tree (needs _index.md convention).
|
|
3060
|
+
*/
|
|
3061
|
+
function hasChildren(docId, treeData) {
|
|
3062
|
+
for (const entry of Object.values(treeData)) if (entry.parentId === docId) return true;
|
|
3063
|
+
return false;
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Resolve filename collisions by appending ~XXXX (first 4 chars of docId).
|
|
3067
|
+
* Returns the filename (without extension) that should be used.
|
|
3068
|
+
*/
|
|
3069
|
+
function resolveCollision(desiredFilename, docId, parentPath, manifest, isIndex) {
|
|
3070
|
+
const ext = ".md";
|
|
3071
|
+
const desiredRelative = isIndex ? `${parentPath}${parentPath ? "/" : ""}${desiredFilename}/_index${ext}` : `${parentPath}${parentPath ? "/" : ""}${desiredFilename}${ext}`;
|
|
3072
|
+
for (const [entryDocId, entry] of Object.entries(manifest.entries)) {
|
|
3073
|
+
if (entryDocId === docId) continue;
|
|
3074
|
+
if (entry.relativePath === desiredRelative) return `${desiredFilename}~${docId.substring(0, 4)}`;
|
|
3075
|
+
}
|
|
3076
|
+
return desiredFilename;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Build the relative path for a document (from syncDir root).
|
|
3080
|
+
* Handles:
|
|
3081
|
+
* - Ancestor chain walking
|
|
3082
|
+
* - _index.md for docs with children
|
|
3083
|
+
* - Collision resolution via ~XXXX suffix
|
|
3084
|
+
*/
|
|
3085
|
+
function buildRelativePath(docId, treeData, manifest) {
|
|
3086
|
+
const entry = treeData[docId];
|
|
3087
|
+
if (!entry) return `${docId}.md`;
|
|
3088
|
+
const segments = [];
|
|
3089
|
+
let current = entry;
|
|
3090
|
+
let currentId = docId;
|
|
3091
|
+
const visited = /* @__PURE__ */ new Set();
|
|
3092
|
+
while (current) {
|
|
3093
|
+
if (visited.has(currentId)) break;
|
|
3094
|
+
visited.add(currentId);
|
|
3095
|
+
segments.unshift(labelToFilename(current.label));
|
|
3096
|
+
if (!current.parentId) break;
|
|
3097
|
+
currentId = current.parentId;
|
|
3098
|
+
current = treeData[currentId];
|
|
3099
|
+
}
|
|
3100
|
+
const filename = segments.pop();
|
|
3101
|
+
const parentPath = segments.join("/");
|
|
3102
|
+
const isIndex = hasChildren(docId, treeData);
|
|
3103
|
+
const resolvedFilename = resolveCollision(filename, docId, parentPath, manifest, isIndex);
|
|
3104
|
+
if (isIndex) return `${parentPath}${parentPath ? "/" : ""}${resolvedFilename}/_index.md`;
|
|
3105
|
+
return `${parentPath}${parentPath ? "/" : ""}${resolvedFilename}.md`;
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Get the directory portion of a relative path for a doc.
|
|
3109
|
+
* For _index.md docs: returns the directory containing _index.md
|
|
3110
|
+
* For leaf docs: returns the parent directory
|
|
3111
|
+
*/
|
|
3112
|
+
function getDocDir(relativePath) {
|
|
3113
|
+
if (relativePath.endsWith("/_index.md")) return relativePath.replace("/_index.md", "");
|
|
3114
|
+
const lastSlash = relativePath.lastIndexOf("/");
|
|
3115
|
+
return lastSlash >= 0 ? relativePath.substring(0, lastSlash) : "";
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Determine the parent docId from a filesystem relative path by walking the
|
|
3119
|
+
* path segments and matching against the tree.
|
|
3120
|
+
*/
|
|
3121
|
+
function resolveParentFromPath(relativePath, treeData) {
|
|
3122
|
+
const parts = relativePath.split("/");
|
|
3123
|
+
parts.pop();
|
|
3124
|
+
if (relativePath.endsWith("/_index.md") && parts.length > 0) parts.pop();
|
|
3125
|
+
if (parts.length === 0) return null;
|
|
3126
|
+
let parentId = null;
|
|
3127
|
+
for (const segment of parts) {
|
|
3128
|
+
const found = Object.entries(treeData).find(([, e]) => labelToFilename(e.label) === segment && e.parentId === parentId);
|
|
3129
|
+
if (found) parentId = found[0];
|
|
3130
|
+
else break;
|
|
3131
|
+
}
|
|
3132
|
+
return parentId;
|
|
3133
|
+
}
|
|
3134
|
+
/**
|
|
3135
|
+
* Simple string hash (same as current useFsSync).
|
|
3136
|
+
*/
|
|
3137
|
+
function simpleHash(str) {
|
|
3138
|
+
let hash = 0;
|
|
3139
|
+
for (let i = 0; i < str.length; i++) hash = (hash << 5) - hash + str.charCodeAt(i) | 0;
|
|
3140
|
+
return hash.toString(36);
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Get all tree data as a flat record.
|
|
3144
|
+
*/
|
|
3145
|
+
function getTreeData(treeMap) {
|
|
3146
|
+
const data = {};
|
|
3147
|
+
treeMap.forEach((val, key) => {
|
|
3148
|
+
if (val && typeof val === "object") data[key] = val;
|
|
3149
|
+
});
|
|
3150
|
+
return data;
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Find the next order value for a given parent (max sibling order + 1).
|
|
3154
|
+
*/
|
|
3155
|
+
function nextOrder(treeData, parentId) {
|
|
3156
|
+
let max = -1;
|
|
3157
|
+
for (const entry of Object.values(treeData)) if (entry.parentId === parentId && entry.order > max) max = entry.order;
|
|
3158
|
+
return max + 1;
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
//#endregion
|
|
3162
|
+
export { MARK_SPECS, MARK_SPEC_BY_DELIM, MARK_SPEC_BY_NAME, NODE_SPECS, NODE_SPEC_BY_NAME, UNIVERSAL_META_KEYS, UNIVERSAL_META_KEY_NAMES, 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 };
|
|
3163
|
+
//# sourceMappingURL=abracadabra-convert.esm.js.map
|