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