@threadlabs/looma 0.29.12 → 0.29.13
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/EXPRESSIVE-MARKDOWN.md +90 -0
- package/README.md +6 -0
- package/components/ui-tabs/ui-tabs.html +2 -2
- package/components/ui-tabs/ui-tabs.js +14 -10
- package/dist/index.js +1 -1
- package/editor/chunks/extensions.js +80 -251
- package/editor/chunks/markdown.js +242 -0
- package/editor/expressmark.d.ts +31 -0
- package/editor/expressmark.js +942 -0
- package/editor/extensions/index.js +2 -1
- package/editor/extensions/table-of-contents.d.ts +1 -1
- package/editor/index.js +2 -1
- package/editor/node-views/table-of-contents.d.ts +1 -1
- package/package.json +10 -3
- package/vue/UiTabs.vue +2 -2
- package/vue/chunks/UiTabs.js +3 -3
- package/vue/editor/index.js +42 -6
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
import { l as loomaHeadingSlug, n as LOOMA_CHIP_TONES, t as LOOMA_CALLOUT_TONES } from "./chunks/markdown.js";
|
|
2
|
+
import { parse, stringify } from "expressmark";
|
|
3
|
+
//#region src/editor/expressmark.ts
|
|
4
|
+
/**
|
|
5
|
+
* A conversion cannot faithfully represent the named input at this document location.
|
|
6
|
+
* @failure Callers retain the original document and can report the unsupported feature and path.
|
|
7
|
+
*/
|
|
8
|
+
var EditorMarkdownConversionError = class extends Error {
|
|
9
|
+
path;
|
|
10
|
+
feature;
|
|
11
|
+
constructor(path, feature) {
|
|
12
|
+
super(`Cannot convert ${feature} at ${path} without losing document data.`);
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.feature = feature;
|
|
15
|
+
this.name = "EditorMarkdownConversionError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var fail = (path, feature) => {
|
|
19
|
+
throw new EditorMarkdownConversionError(path, feature);
|
|
20
|
+
};
|
|
21
|
+
var fields = (node, names, path) => {
|
|
22
|
+
for (const [name, value] of Object.entries(node.attrs ?? {})) if (value !== null && value !== void 0 && !names.includes(name)) fail(path, `attribute ${name}`);
|
|
23
|
+
};
|
|
24
|
+
var string = (value, fallback = "") => typeof value === "string" ? value : fallback;
|
|
25
|
+
var title = (value) => ({
|
|
26
|
+
type: "title",
|
|
27
|
+
value
|
|
28
|
+
});
|
|
29
|
+
var content = (node) => node.body ?? node.children ?? (node.value !== void 0 ? [{
|
|
30
|
+
type: "text",
|
|
31
|
+
value: node.value
|
|
32
|
+
}] : []);
|
|
33
|
+
var text = (node) => node.value ?? content(node).map(text).join("");
|
|
34
|
+
var plainTitle = (node, path) => {
|
|
35
|
+
if (node === void 0) return "";
|
|
36
|
+
if (typeof node === "string" || node.type !== "title" || node.id || node.attributes || node.body || node.children?.some((child) => child.type !== "text" || child.id || child.attributes)) fail(path, "formatted or attributed title");
|
|
37
|
+
return text(node);
|
|
38
|
+
};
|
|
39
|
+
var AST_FIELDS = {
|
|
40
|
+
text: [],
|
|
41
|
+
paragraph: [],
|
|
42
|
+
heading: ["level"],
|
|
43
|
+
break: [],
|
|
44
|
+
softBreak: [],
|
|
45
|
+
horizontalRule: [],
|
|
46
|
+
blockquote: [],
|
|
47
|
+
list: [
|
|
48
|
+
"ordered",
|
|
49
|
+
"start",
|
|
50
|
+
"tight"
|
|
51
|
+
],
|
|
52
|
+
listItem: ["checked"],
|
|
53
|
+
code: [
|
|
54
|
+
"language",
|
|
55
|
+
"meta",
|
|
56
|
+
"title"
|
|
57
|
+
],
|
|
58
|
+
image: [
|
|
59
|
+
"url",
|
|
60
|
+
"alt",
|
|
61
|
+
"title"
|
|
62
|
+
],
|
|
63
|
+
inline: ["subType", "ref"],
|
|
64
|
+
container: ["subType", "title"],
|
|
65
|
+
table: ["align", "title"],
|
|
66
|
+
tableRow: [],
|
|
67
|
+
tableCell: [
|
|
68
|
+
"header",
|
|
69
|
+
"colSpan",
|
|
70
|
+
"rowSpan"
|
|
71
|
+
],
|
|
72
|
+
strong: [],
|
|
73
|
+
emphasis: [],
|
|
74
|
+
delete: [],
|
|
75
|
+
underline: [],
|
|
76
|
+
highlight: [],
|
|
77
|
+
inlineCode: [],
|
|
78
|
+
link: ["url", "title"]
|
|
79
|
+
};
|
|
80
|
+
var has = (attrs, key, value) => {
|
|
81
|
+
if (typeof value === "string" || typeof value === "boolean") attrs[key] = value;
|
|
82
|
+
else if (typeof value === "number" && Number.isFinite(value)) attrs[key] = String(value);
|
|
83
|
+
};
|
|
84
|
+
var compact = (type, children, extra = {}) => children.length === 0 || children.every((node) => node.type === "text" && Object.keys(node).every((key) => key === "type" || key === "value")) ? {
|
|
85
|
+
type,
|
|
86
|
+
...extra,
|
|
87
|
+
value: children.map((node) => node.value).join("")
|
|
88
|
+
} : {
|
|
89
|
+
type,
|
|
90
|
+
...extra,
|
|
91
|
+
children
|
|
92
|
+
};
|
|
93
|
+
function findNode(document, predicate) {
|
|
94
|
+
if (predicate(document)) return document;
|
|
95
|
+
for (const child of document.body ?? document.children ?? []) {
|
|
96
|
+
const found = findNode(child, predicate);
|
|
97
|
+
if (found) return found;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function safeBackground(value, path) {
|
|
101
|
+
if (typeof value !== "string") fail(path, "nonstring table background");
|
|
102
|
+
if (value.includes(";")) fail(path, "table background containing declarations");
|
|
103
|
+
const style = `background-color:${value}`;
|
|
104
|
+
const accepted = findNode(parse(stringify({
|
|
105
|
+
type: "document",
|
|
106
|
+
version: 1,
|
|
107
|
+
body: [{
|
|
108
|
+
type: "paragraph",
|
|
109
|
+
value: "color",
|
|
110
|
+
attributes: { style }
|
|
111
|
+
}]
|
|
112
|
+
})), (node) => typeof node.attributes?.style === "string")?.attributes?.style;
|
|
113
|
+
if (typeof accepted !== "string" || accepted.replace(/\s/g, "").replace(/;$/, "") !== style.replace(/\s/g, "")) fail(path, "unsafe or unsupported table background");
|
|
114
|
+
}
|
|
115
|
+
function safeNavigation(value, image, path) {
|
|
116
|
+
if (typeof value !== "string") fail(path, "nonstring navigation URL");
|
|
117
|
+
if (findNode(parse(stringify({
|
|
118
|
+
type: "document",
|
|
119
|
+
version: 1,
|
|
120
|
+
body: [{
|
|
121
|
+
type: "paragraph",
|
|
122
|
+
children: [image ? {
|
|
123
|
+
type: "image",
|
|
124
|
+
url: value,
|
|
125
|
+
alt: "image"
|
|
126
|
+
} : {
|
|
127
|
+
type: "link",
|
|
128
|
+
url: value,
|
|
129
|
+
value: "link"
|
|
130
|
+
}]
|
|
131
|
+
}]
|
|
132
|
+
})), (child) => child.type === (image ? "image" : "link"))?.url !== value) fail(path, "unsafe or unsupported navigation URL");
|
|
133
|
+
}
|
|
134
|
+
var MARKS = {
|
|
135
|
+
bold: "strong",
|
|
136
|
+
italic: "emphasis",
|
|
137
|
+
strike: "delete",
|
|
138
|
+
underline: "underline",
|
|
139
|
+
highlight: "highlight",
|
|
140
|
+
code: "inlineCode",
|
|
141
|
+
link: "link"
|
|
142
|
+
};
|
|
143
|
+
function legacyCodePresentation(code, path) {
|
|
144
|
+
let remaining = code.meta ?? "";
|
|
145
|
+
let unknown = "";
|
|
146
|
+
let translated = false;
|
|
147
|
+
const attributes = { ...code.attributes };
|
|
148
|
+
const result = {
|
|
149
|
+
...code,
|
|
150
|
+
attributes
|
|
151
|
+
};
|
|
152
|
+
while (remaining) {
|
|
153
|
+
const spacing = /^[ \t]+/.exec(remaining)?.[0];
|
|
154
|
+
if (spacing) {
|
|
155
|
+
unknown += spacing;
|
|
156
|
+
remaining = remaining.slice(spacing.length);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const token = /^(?:title="([^"]*)"|\{(\d+(?:[ \t]*-[ \t]*\d+)?(?:[ \t]*,[ \t]*\d+(?:[ \t]*-[ \t]*\d+)?)*)\}|:(no-)?line-numbers)(?=$|[ \t:{])/.exec(remaining);
|
|
160
|
+
if (token) {
|
|
161
|
+
const key = token[1] !== void 0 ? "title" : token[2] !== void 0 ? "highlight" : "showLineNumbers";
|
|
162
|
+
const value = token[1] ?? (token[2] !== void 0 ? token[2].replace(/[ \t]/g, "") : token[3] === void 0);
|
|
163
|
+
const previous = key === "title" ? result.title : attributes[key];
|
|
164
|
+
if (previous !== void 0 && previous !== value) fail(path, `conflicting code ${key} metadata`);
|
|
165
|
+
if (key === "title") result.title = value;
|
|
166
|
+
else attributes[key] = value;
|
|
167
|
+
remaining = remaining.slice(token[0].length);
|
|
168
|
+
translated = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const opaque = /^(?:[^"' \t]+|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')+/.exec(remaining)?.[0] ?? remaining[0];
|
|
172
|
+
unknown += opaque;
|
|
173
|
+
remaining = remaining.slice(opaque.length);
|
|
174
|
+
}
|
|
175
|
+
if (!translated) return code;
|
|
176
|
+
delete result.meta;
|
|
177
|
+
if (unknown.trim()) result.meta = unknown.trim();
|
|
178
|
+
if (!Object.keys(attributes).length) delete result.attributes;
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
var REVERSE_MARKS = Object.fromEntries(Object.entries(MARKS).map(([left, right]) => [right, left]));
|
|
182
|
+
function tocBody(headings, depth, ordered) {
|
|
183
|
+
const roots = [];
|
|
184
|
+
const stack = [];
|
|
185
|
+
for (const heading of headings.filter((item) => item.level <= depth)) {
|
|
186
|
+
while (stack.length && stack[stack.length - 1].level >= heading.level) stack.pop();
|
|
187
|
+
const item = {
|
|
188
|
+
type: "listItem",
|
|
189
|
+
body: [{
|
|
190
|
+
type: "paragraph",
|
|
191
|
+
children: [{
|
|
192
|
+
type: "link",
|
|
193
|
+
url: `#${heading.id}`,
|
|
194
|
+
value: heading.text,
|
|
195
|
+
attributes: {
|
|
196
|
+
target: "_self",
|
|
197
|
+
rel: "noopener noreferrer"
|
|
198
|
+
}
|
|
199
|
+
}]
|
|
200
|
+
}]
|
|
201
|
+
};
|
|
202
|
+
const parent = stack[stack.length - 1]?.item;
|
|
203
|
+
const destination = parent ? parent.body ??= [] : roots;
|
|
204
|
+
let list = destination[destination.length - 1];
|
|
205
|
+
if (list?.type !== "list") {
|
|
206
|
+
list = {
|
|
207
|
+
type: "list",
|
|
208
|
+
ordered,
|
|
209
|
+
...ordered ? { start: 1 } : {},
|
|
210
|
+
children: []
|
|
211
|
+
};
|
|
212
|
+
destination.push(list);
|
|
213
|
+
}
|
|
214
|
+
list.children.push(item);
|
|
215
|
+
stack.push({
|
|
216
|
+
level: heading.level,
|
|
217
|
+
item
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return roots;
|
|
221
|
+
}
|
|
222
|
+
function toNode(node, path, context) {
|
|
223
|
+
let result = toBareNode(node, path, context);
|
|
224
|
+
if (!node.marks?.length) return result;
|
|
225
|
+
if (![
|
|
226
|
+
"text",
|
|
227
|
+
"mention",
|
|
228
|
+
"loomaChip",
|
|
229
|
+
"hardBreak"
|
|
230
|
+
].includes(node.type ?? "")) fail(path, "marks on a block node");
|
|
231
|
+
for (const mark of [...node.marks ?? []].reverse()) {
|
|
232
|
+
const type = MARKS[mark.type];
|
|
233
|
+
if (!type) fail(path, `mark ${mark.type}`);
|
|
234
|
+
if (mark.type === "highlight" && mark.attrs?.color != null) fail(path, "colored highlight (the default editor uses one highlight style)");
|
|
235
|
+
fields({ attrs: mark.attrs }, mark.type === "link" ? [
|
|
236
|
+
"href",
|
|
237
|
+
"title",
|
|
238
|
+
"target",
|
|
239
|
+
"rel",
|
|
240
|
+
"class"
|
|
241
|
+
] : mark.type === "highlight" ? ["color"] : [], path);
|
|
242
|
+
if (type === "link") {
|
|
243
|
+
const attributes = {};
|
|
244
|
+
for (const key of [
|
|
245
|
+
"target",
|
|
246
|
+
"rel",
|
|
247
|
+
"class"
|
|
248
|
+
]) has(attributes, key, mark.attrs?.[key]);
|
|
249
|
+
result = compact(type, [result], {
|
|
250
|
+
url: string(mark.attrs?.href),
|
|
251
|
+
...mark.attrs?.title ? { title: string(mark.attrs.title) } : {},
|
|
252
|
+
...Object.keys(attributes).length ? { attributes } : {}
|
|
253
|
+
});
|
|
254
|
+
} else if (type === "inlineCode") {
|
|
255
|
+
if (result.type !== "text") fail(path, "code combined with another mark");
|
|
256
|
+
result = {
|
|
257
|
+
type,
|
|
258
|
+
value: result.value ?? ""
|
|
259
|
+
};
|
|
260
|
+
} else result = compact(type, [result], mark.attrs?.color ? { attributes: { color: string(mark.attrs.color) } } : {});
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
function toBareNode(node, path, context) {
|
|
265
|
+
const attrs = node.attrs ?? {};
|
|
266
|
+
const children = () => (node.content ?? []).map((child, index) => toNode(child, `${path}.body[${index}]`, context));
|
|
267
|
+
const blocks = () => children().map((child) => child.type === "image" ? {
|
|
268
|
+
type: "paragraph",
|
|
269
|
+
children: [child]
|
|
270
|
+
} : child);
|
|
271
|
+
const plain = (type, keys = [], extra = {}) => {
|
|
272
|
+
fields(node, keys, path);
|
|
273
|
+
return compact(type, children(), extra);
|
|
274
|
+
};
|
|
275
|
+
const block = (type, keys = [], extra = {}) => {
|
|
276
|
+
fields(node, keys, path);
|
|
277
|
+
return {
|
|
278
|
+
type,
|
|
279
|
+
...extra,
|
|
280
|
+
body: blocks()
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
switch (node.type) {
|
|
284
|
+
case "text":
|
|
285
|
+
fields(node, [], path);
|
|
286
|
+
return {
|
|
287
|
+
type: "text",
|
|
288
|
+
value: node.text ?? ""
|
|
289
|
+
};
|
|
290
|
+
case "paragraph": return plain("paragraph");
|
|
291
|
+
case "heading": {
|
|
292
|
+
const id = attrs.id ? string(attrs.id) : context.headingIds.get(node);
|
|
293
|
+
return plain("heading", ["level", "id"], {
|
|
294
|
+
level: Number(attrs.level ?? 1),
|
|
295
|
+
...id ? { id } : {}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
case "blockquote": return block("blockquote");
|
|
299
|
+
case "horizontalRule":
|
|
300
|
+
fields(node, [], path);
|
|
301
|
+
return { type: "horizontalRule" };
|
|
302
|
+
case "hardBreak":
|
|
303
|
+
fields(node, [], path);
|
|
304
|
+
return { type: "break" };
|
|
305
|
+
case "bulletList":
|
|
306
|
+
case "orderedList":
|
|
307
|
+
case "taskList":
|
|
308
|
+
fields(node, ["start"], path);
|
|
309
|
+
return {
|
|
310
|
+
type: "list",
|
|
311
|
+
ordered: node.type === "orderedList",
|
|
312
|
+
...node.type === "orderedList" ? { start: Number(attrs.start ?? 1) } : {},
|
|
313
|
+
children: children()
|
|
314
|
+
};
|
|
315
|
+
case "listItem": return block("listItem");
|
|
316
|
+
case "taskItem": return block("listItem", ["checked"], { checked: attrs.checked === true });
|
|
317
|
+
case "codeBlock": {
|
|
318
|
+
fields(node, ["language", "meta"], path);
|
|
319
|
+
if ((node.content ?? []).some((child) => child.type !== "text" || child.marks?.length)) fail(path, "nonliteral code content");
|
|
320
|
+
if (/[\r\n]/.test(string(attrs.meta)) || /[\s`~]/.test(string(attrs.language))) fail(path, "code fence information");
|
|
321
|
+
const parsed = parse(`\`\`\`${string(attrs.language)} ${string(attrs.meta)}\n\`\`\``).body[0];
|
|
322
|
+
if (parsed?.type !== "code") fail(path, "code fence information");
|
|
323
|
+
return {
|
|
324
|
+
...legacyCodePresentation(parsed, path),
|
|
325
|
+
value: (node.content ?? []).map((child) => child.text ?? "").join("")
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
case "image": {
|
|
329
|
+
fields(node, [
|
|
330
|
+
"src",
|
|
331
|
+
"alt",
|
|
332
|
+
"title",
|
|
333
|
+
"width",
|
|
334
|
+
"height",
|
|
335
|
+
"placement",
|
|
336
|
+
"responsive"
|
|
337
|
+
], path);
|
|
338
|
+
const attributes = {};
|
|
339
|
+
for (const key of [
|
|
340
|
+
"width",
|
|
341
|
+
"height",
|
|
342
|
+
"placement",
|
|
343
|
+
"responsive"
|
|
344
|
+
]) has(attributes, key, attrs[key]);
|
|
345
|
+
return {
|
|
346
|
+
type: "image",
|
|
347
|
+
url: string(attrs.src),
|
|
348
|
+
alt: string(attrs.alt),
|
|
349
|
+
...attrs.title ? { title: string(attrs.title) } : {},
|
|
350
|
+
...Object.keys(attributes).length ? { attributes } : {}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
case "mention":
|
|
354
|
+
fields(node, ["id", "label"], path);
|
|
355
|
+
return {
|
|
356
|
+
type: "inline",
|
|
357
|
+
subType: "mention",
|
|
358
|
+
value: string(attrs.label, string(attrs.id)),
|
|
359
|
+
...attrs.id ? { ref: string(attrs.id) } : {}
|
|
360
|
+
};
|
|
361
|
+
case "loomaChip":
|
|
362
|
+
fields(node, ["label", "color"], path);
|
|
363
|
+
return {
|
|
364
|
+
type: "inline",
|
|
365
|
+
subType: "chip",
|
|
366
|
+
value: string(attrs.label),
|
|
367
|
+
attributes: { tone: string(attrs.color, "neutral") }
|
|
368
|
+
};
|
|
369
|
+
case "loomaCallout": return block("container", ["tone", "heading"], {
|
|
370
|
+
subType: string(attrs.tone, "note"),
|
|
371
|
+
...attrs.heading ? { title: title(string(attrs.heading)) } : {}
|
|
372
|
+
});
|
|
373
|
+
case "loomaExpand": return block("container", ["summary", "open"], {
|
|
374
|
+
subType: "details",
|
|
375
|
+
title: title(string(attrs.summary, "Details")),
|
|
376
|
+
attributes: { open: attrs.open === true }
|
|
377
|
+
});
|
|
378
|
+
case "loomaTabs":
|
|
379
|
+
fields(node, [
|
|
380
|
+
"group",
|
|
381
|
+
"codeGroup",
|
|
382
|
+
"defaultValue",
|
|
383
|
+
"queryString",
|
|
384
|
+
"lazy"
|
|
385
|
+
], path);
|
|
386
|
+
for (const key of [
|
|
387
|
+
"group",
|
|
388
|
+
"defaultValue",
|
|
389
|
+
"queryString"
|
|
390
|
+
]) if (attrs[key] !== null && attrs[key] !== void 0 && typeof attrs[key] !== "string") fail(path, `nonstring tab ${key}`);
|
|
391
|
+
if (attrs.lazy !== null && attrs.lazy !== void 0 && typeof attrs.lazy !== "boolean") fail(path, "nonboolean tab lazy flag");
|
|
392
|
+
const attributes = {};
|
|
393
|
+
for (const key of [
|
|
394
|
+
"group",
|
|
395
|
+
"queryString",
|
|
396
|
+
"lazy"
|
|
397
|
+
]) if (attrs[key] !== "") has(attributes, key, attrs[key]);
|
|
398
|
+
has(attributes, "default", attrs.defaultValue);
|
|
399
|
+
return {
|
|
400
|
+
type: "container",
|
|
401
|
+
subType: "tabs",
|
|
402
|
+
...Object.keys(attributes).length ? { attributes } : {},
|
|
403
|
+
body: blocks()
|
|
404
|
+
};
|
|
405
|
+
case "loomaTab": return block("container", ["label", "value"], {
|
|
406
|
+
subType: "tab",
|
|
407
|
+
title: title(string(attrs.label)),
|
|
408
|
+
...attrs.value !== void 0 && attrs.value !== null ? { attributes: { value: string(attrs.value) } } : {}
|
|
409
|
+
});
|
|
410
|
+
case "loomaFigure": {
|
|
411
|
+
fields(node, [], path);
|
|
412
|
+
const image = node.content?.[0];
|
|
413
|
+
const caption = node.content?.[1];
|
|
414
|
+
if (node.content?.length !== 2 || image?.type !== "image" || caption?.type !== "loomaFigcaption") fail(path, "figure shape");
|
|
415
|
+
fields(caption, [], `${path}.caption`);
|
|
416
|
+
return {
|
|
417
|
+
type: "container",
|
|
418
|
+
subType: "figure",
|
|
419
|
+
title: compact("title", (caption.content ?? []).map((child, index) => toNode(child, `${path}.title[${index}]`, context))),
|
|
420
|
+
body: [{
|
|
421
|
+
type: "paragraph",
|
|
422
|
+
children: [toNode(image, `${path}.body[0]`, context)]
|
|
423
|
+
}]
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
case "table":
|
|
427
|
+
fields(node, [], path);
|
|
428
|
+
return {
|
|
429
|
+
type: "table",
|
|
430
|
+
children: children()
|
|
431
|
+
};
|
|
432
|
+
case "tableRow":
|
|
433
|
+
fields(node, [], path);
|
|
434
|
+
return {
|
|
435
|
+
type: "tableRow",
|
|
436
|
+
children: children()
|
|
437
|
+
};
|
|
438
|
+
case "tableCell":
|
|
439
|
+
case "tableHeader": {
|
|
440
|
+
fields(node, [
|
|
441
|
+
"colspan",
|
|
442
|
+
"rowspan",
|
|
443
|
+
"colwidth",
|
|
444
|
+
"textAlign",
|
|
445
|
+
"backgroundColor"
|
|
446
|
+
], path);
|
|
447
|
+
const attributes = {};
|
|
448
|
+
for (const key of ["textAlign", "backgroundColor"]) has(attributes, key, attrs[key]);
|
|
449
|
+
if (attrs.colwidth != null) {
|
|
450
|
+
if (!Array.isArray(attrs.colwidth) || attrs.colwidth.some((value) => typeof value !== "number" || !Number.isFinite(value))) fail(path, "column widths");
|
|
451
|
+
attributes.columnWidths = attrs.colwidth.join(",");
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
type: "tableCell",
|
|
455
|
+
header: node.type === "tableHeader",
|
|
456
|
+
colSpan: Number(attrs.colspan ?? 1),
|
|
457
|
+
rowSpan: Number(attrs.rowspan ?? 1),
|
|
458
|
+
...Object.keys(attributes).length ? { attributes } : {},
|
|
459
|
+
body: blocks()
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
case "loomaTableOfContents": {
|
|
463
|
+
fields(node, ["format", "depth"], path);
|
|
464
|
+
const depth = Number(attrs.depth ?? 3);
|
|
465
|
+
return {
|
|
466
|
+
type: "container",
|
|
467
|
+
subType: "toc",
|
|
468
|
+
attributes: {
|
|
469
|
+
depth: String(depth),
|
|
470
|
+
format: string(attrs.format, "plain")
|
|
471
|
+
},
|
|
472
|
+
body: node.content?.length ? blocks() : tocBody(context.headings, depth, attrs.format === "numbered")
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
default: return fail(path, `node ${node.type ?? "unknown"}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Maps an editor's saved JSON to the independent Expressive Markdown AST without DOM access.
|
|
480
|
+
* @contract Unsupported nodes, marks and durable attributes throw with their location; input is never mutated.
|
|
481
|
+
*/
|
|
482
|
+
function editorDocumentToExpressmark(document, options = {}) {
|
|
483
|
+
if (document.type !== "doc") fail("document", "non-document root");
|
|
484
|
+
fields(document, [], "document");
|
|
485
|
+
const headings = [];
|
|
486
|
+
let automaticToc = false;
|
|
487
|
+
const colors = /* @__PURE__ */ new Set();
|
|
488
|
+
const urls = /* @__PURE__ */ new Set();
|
|
489
|
+
const visit = (node) => {
|
|
490
|
+
const attrs = node.attrs ?? {};
|
|
491
|
+
if (node.type === "tableCell" || node.type === "tableHeader") {
|
|
492
|
+
if (attrs.backgroundColor != null && !colors.has(attrs.backgroundColor)) {
|
|
493
|
+
safeBackground(attrs.backgroundColor, "document");
|
|
494
|
+
colors.add(attrs.backgroundColor);
|
|
495
|
+
}
|
|
496
|
+
if (attrs.textAlign != null && ![
|
|
497
|
+
"left",
|
|
498
|
+
"center",
|
|
499
|
+
"right"
|
|
500
|
+
].includes(String(attrs.textAlign))) fail("document", "unsupported table alignment");
|
|
501
|
+
}
|
|
502
|
+
if (node.type === "image") {
|
|
503
|
+
const key = `image:${attrs.src}`;
|
|
504
|
+
if (!urls.has(key)) {
|
|
505
|
+
safeNavigation(attrs.src, true, "document");
|
|
506
|
+
urls.add(key);
|
|
507
|
+
}
|
|
508
|
+
if (attrs.placement != null && ![
|
|
509
|
+
"block",
|
|
510
|
+
"center",
|
|
511
|
+
"wrap-left",
|
|
512
|
+
"wrap-right"
|
|
513
|
+
].includes(String(attrs.placement))) fail("document", "unsupported image placement");
|
|
514
|
+
if (attrs.responsive != null && typeof attrs.responsive !== "boolean") fail("document", "nonboolean responsive flag");
|
|
515
|
+
}
|
|
516
|
+
for (const mark of node.marks ?? []) if (mark.type === "link") {
|
|
517
|
+
const key = `link:${mark.attrs?.href}`;
|
|
518
|
+
if (!urls.has(key)) {
|
|
519
|
+
safeNavigation(mark.attrs?.href, false, "document");
|
|
520
|
+
urls.add(key);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (node.type === "loomaTableOfContents" && !node.content?.length) automaticToc = true;
|
|
524
|
+
if (node.type === "heading") headings.push({
|
|
525
|
+
node,
|
|
526
|
+
text: (node.content ?? []).map((child) => child.text ?? "").join(""),
|
|
527
|
+
id: string(node.attrs?.id),
|
|
528
|
+
level: Number(node.attrs?.level ?? 1)
|
|
529
|
+
});
|
|
530
|
+
for (const child of node.content ?? []) visit(child);
|
|
531
|
+
};
|
|
532
|
+
visit(document);
|
|
533
|
+
const headingIds = /* @__PURE__ */ new WeakMap();
|
|
534
|
+
const reserved = new Set(headings.map((heading) => heading.id).filter(Boolean));
|
|
535
|
+
const used = new Set(reserved);
|
|
536
|
+
const authored = /* @__PURE__ */ new Set();
|
|
537
|
+
for (const heading of headings) {
|
|
538
|
+
if (heading.id) {
|
|
539
|
+
if (authored.has(heading.id)) options.onDiagnostic?.({
|
|
540
|
+
code: "duplicate-id",
|
|
541
|
+
severity: "warning",
|
|
542
|
+
message: `Authored heading ID "${heading.id}" is repeated and was preserved.`
|
|
543
|
+
});
|
|
544
|
+
authored.add(heading.id);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const stem = loomaHeadingSlug(heading.text);
|
|
548
|
+
let candidate = stem;
|
|
549
|
+
for (let suffix = 1; used.has(candidate); suffix++) candidate = `${stem}-${suffix}`;
|
|
550
|
+
heading.id = candidate;
|
|
551
|
+
used.add(candidate);
|
|
552
|
+
if (automaticToc && heading.node) headingIds.set(heading.node, candidate);
|
|
553
|
+
}
|
|
554
|
+
const context = {
|
|
555
|
+
headings,
|
|
556
|
+
headingIds
|
|
557
|
+
};
|
|
558
|
+
return {
|
|
559
|
+
type: "document",
|
|
560
|
+
version: 1,
|
|
561
|
+
body: (document.content ?? []).map((node, index) => {
|
|
562
|
+
const child = toNode(node, `document.body[${index}]`, context);
|
|
563
|
+
return child.type === "image" ? {
|
|
564
|
+
type: "paragraph",
|
|
565
|
+
children: [child]
|
|
566
|
+
} : child;
|
|
567
|
+
})
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function fromNode(node, path, marks = []) {
|
|
571
|
+
const attrs = node.attributes ?? {};
|
|
572
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
573
|
+
"type",
|
|
574
|
+
"id",
|
|
575
|
+
"attributes",
|
|
576
|
+
"body",
|
|
577
|
+
"children",
|
|
578
|
+
"value",
|
|
579
|
+
"position",
|
|
580
|
+
"source",
|
|
581
|
+
...AST_FIELDS[node.type] ?? []
|
|
582
|
+
]);
|
|
583
|
+
for (const key of Object.keys(node)) if (!allowed.has(key)) fail(path, `AST field ${key}`);
|
|
584
|
+
if ([
|
|
585
|
+
node.body,
|
|
586
|
+
node.children,
|
|
587
|
+
node.value
|
|
588
|
+
].filter((value) => value !== void 0).length > 1) fail(path, "conflicting content fields");
|
|
589
|
+
const check = (keys = []) => {
|
|
590
|
+
for (const key of Object.keys(attrs)) if (!keys.includes(key)) fail(path, `attribute ${key}`);
|
|
591
|
+
if (node.id && node.type !== "heading") fail(path, `ID on ${node.type}`);
|
|
592
|
+
};
|
|
593
|
+
const children = () => content(node).flatMap((child, index) => fromNode(child, `${path}.body[${index}]`));
|
|
594
|
+
const inline = () => content(node).flatMap((child, index) => fromNode(child, `${path}.children[${index}]`, marks));
|
|
595
|
+
const wrap = (type, attributes) => [{
|
|
596
|
+
type,
|
|
597
|
+
...attributes ? { attrs: attributes } : {},
|
|
598
|
+
...content(node).length ? { content: children() } : {}
|
|
599
|
+
}];
|
|
600
|
+
if (REVERSE_MARKS[node.type]) {
|
|
601
|
+
check(node.type === "link" ? [
|
|
602
|
+
"target",
|
|
603
|
+
"rel",
|
|
604
|
+
"class"
|
|
605
|
+
] : []);
|
|
606
|
+
const mark = { type: REVERSE_MARKS[node.type] };
|
|
607
|
+
if (node.type === "link") mark.attrs = {
|
|
608
|
+
href: node.url ?? "",
|
|
609
|
+
...attrs,
|
|
610
|
+
...typeof node.title === "string" ? { title: node.title } : {}
|
|
611
|
+
};
|
|
612
|
+
else if (Object.keys(attrs).length) mark.attrs = { ...attrs };
|
|
613
|
+
return content(node).flatMap((child, index) => fromNode(child, `${path}.children[${index}]`, [...marks, mark]));
|
|
614
|
+
}
|
|
615
|
+
switch (node.type) {
|
|
616
|
+
case "text":
|
|
617
|
+
check();
|
|
618
|
+
return node.value ? [{
|
|
619
|
+
type: "text",
|
|
620
|
+
text: node.value,
|
|
621
|
+
...marks.length ? { marks } : {}
|
|
622
|
+
}] : [];
|
|
623
|
+
case "paragraph": {
|
|
624
|
+
check();
|
|
625
|
+
const items = content(node);
|
|
626
|
+
if (items.length === 1 && items[0]?.type === "image") return fromNode(items[0], `${path}.children[0]`);
|
|
627
|
+
if (items.some((child) => child.type === "image")) fail(path, "image mixed with inline text (the default editor uses block images)");
|
|
628
|
+
return [{
|
|
629
|
+
type: "paragraph",
|
|
630
|
+
...items.length ? { content: inline() } : {}
|
|
631
|
+
}];
|
|
632
|
+
}
|
|
633
|
+
case "heading":
|
|
634
|
+
check();
|
|
635
|
+
return [{
|
|
636
|
+
type: "heading",
|
|
637
|
+
attrs: {
|
|
638
|
+
level: node.level ?? 1,
|
|
639
|
+
...node.id ? { id: node.id } : {}
|
|
640
|
+
},
|
|
641
|
+
...content(node).length ? { content: inline() } : {}
|
|
642
|
+
}];
|
|
643
|
+
case "break":
|
|
644
|
+
check();
|
|
645
|
+
return [{
|
|
646
|
+
type: "hardBreak",
|
|
647
|
+
...marks.length ? { marks } : {}
|
|
648
|
+
}];
|
|
649
|
+
case "softBreak":
|
|
650
|
+
check();
|
|
651
|
+
return [{
|
|
652
|
+
type: "text",
|
|
653
|
+
text: "\n",
|
|
654
|
+
...marks.length ? { marks } : {}
|
|
655
|
+
}];
|
|
656
|
+
case "horizontalRule":
|
|
657
|
+
check();
|
|
658
|
+
return [{ type: "horizontalRule" }];
|
|
659
|
+
case "blockquote":
|
|
660
|
+
check();
|
|
661
|
+
return wrap("blockquote");
|
|
662
|
+
case "list":
|
|
663
|
+
check();
|
|
664
|
+
return [{
|
|
665
|
+
type: node.children?.some((child) => child.checked !== void 0) ? "taskList" : node.ordered ? "orderedList" : "bulletList",
|
|
666
|
+
...node.ordered ? { attrs: { start: node.start ?? 1 } } : {},
|
|
667
|
+
content: children()
|
|
668
|
+
}];
|
|
669
|
+
case "listItem":
|
|
670
|
+
check();
|
|
671
|
+
return wrap(node.checked !== void 0 ? "taskItem" : "listItem", node.checked !== void 0 ? { checked: node.checked } : void 0);
|
|
672
|
+
case "code": {
|
|
673
|
+
check(Object.keys(attrs));
|
|
674
|
+
const meta = (stringify({
|
|
675
|
+
type: "document",
|
|
676
|
+
version: 1,
|
|
677
|
+
body: [{
|
|
678
|
+
...node,
|
|
679
|
+
value: ""
|
|
680
|
+
}]
|
|
681
|
+
}).split("\n")[0] ?? "").replace(/^`+|^~+/, "").slice(node.language?.length ?? 0).trim();
|
|
682
|
+
return [{
|
|
683
|
+
type: "codeBlock",
|
|
684
|
+
attrs: {
|
|
685
|
+
language: node.language ?? null,
|
|
686
|
+
...meta ? { meta } : {}
|
|
687
|
+
},
|
|
688
|
+
...node.value ? { content: [{
|
|
689
|
+
type: "text",
|
|
690
|
+
text: node.value
|
|
691
|
+
}] } : {}
|
|
692
|
+
}];
|
|
693
|
+
}
|
|
694
|
+
case "image": {
|
|
695
|
+
check([
|
|
696
|
+
"width",
|
|
697
|
+
"height",
|
|
698
|
+
"placement",
|
|
699
|
+
"responsive"
|
|
700
|
+
]);
|
|
701
|
+
if (attrs.placement !== void 0 && ![
|
|
702
|
+
"block",
|
|
703
|
+
"center",
|
|
704
|
+
"wrap-left",
|
|
705
|
+
"wrap-right"
|
|
706
|
+
].includes(String(attrs.placement))) fail(path, "unsupported image placement");
|
|
707
|
+
if (attrs.responsive !== void 0 && typeof attrs.responsive !== "boolean") fail(path, "nonboolean responsive flag");
|
|
708
|
+
const result = {
|
|
709
|
+
src: node.url ?? "",
|
|
710
|
+
alt: node.alt ?? "",
|
|
711
|
+
title: typeof node.title === "string" ? node.title : null
|
|
712
|
+
};
|
|
713
|
+
for (const key of ["width", "height"]) if (attrs[key] !== void 0) {
|
|
714
|
+
const value = Number(attrs[key]);
|
|
715
|
+
if (!Number.isFinite(value) || value <= 0) fail(path, `image ${key}`);
|
|
716
|
+
result[key] = value;
|
|
717
|
+
}
|
|
718
|
+
for (const key of ["placement", "responsive"]) if (attrs[key] !== void 0) result[key] = attrs[key];
|
|
719
|
+
return [{
|
|
720
|
+
type: "image",
|
|
721
|
+
attrs: result
|
|
722
|
+
}];
|
|
723
|
+
}
|
|
724
|
+
case "inline":
|
|
725
|
+
if (node.children?.some((child) => child.type !== "text" || child.id || child.attributes)) fail(path, "formatted inline label");
|
|
726
|
+
if (node.subType === "mention") {
|
|
727
|
+
check();
|
|
728
|
+
return [{
|
|
729
|
+
type: "mention",
|
|
730
|
+
attrs: {
|
|
731
|
+
id: node.ref ?? null,
|
|
732
|
+
label: text(node)
|
|
733
|
+
},
|
|
734
|
+
...marks.length ? { marks } : {}
|
|
735
|
+
}];
|
|
736
|
+
}
|
|
737
|
+
if (node.subType === "chip") {
|
|
738
|
+
check(["tone"]);
|
|
739
|
+
if (attrs.tone !== void 0 && !LOOMA_CHIP_TONES.includes(attrs.tone)) fail(path, "unsupported chip tone");
|
|
740
|
+
return [{
|
|
741
|
+
type: "loomaChip",
|
|
742
|
+
attrs: {
|
|
743
|
+
label: text(node),
|
|
744
|
+
color: attrs.tone ?? "neutral"
|
|
745
|
+
},
|
|
746
|
+
...marks.length ? { marks } : {}
|
|
747
|
+
}];
|
|
748
|
+
}
|
|
749
|
+
return fail(path, `inline type ${node.subType}`);
|
|
750
|
+
case "container": {
|
|
751
|
+
const label = node.subType === "figure" ? "" : plainTitle(node.title, `${path}.title`);
|
|
752
|
+
if (node.subType === "details") {
|
|
753
|
+
check(["open"]);
|
|
754
|
+
if (attrs.open !== void 0 && typeof attrs.open !== "boolean") fail(path, "nonboolean disclosure flag");
|
|
755
|
+
return wrap("loomaExpand", {
|
|
756
|
+
summary: label || "Details",
|
|
757
|
+
open: attrs.open === true
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (LOOMA_CALLOUT_TONES.includes(node.subType ?? "")) {
|
|
761
|
+
check();
|
|
762
|
+
return wrap("loomaCallout", {
|
|
763
|
+
tone: node.subType,
|
|
764
|
+
heading: label
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (node.subType === "tabs") {
|
|
768
|
+
check([
|
|
769
|
+
"group",
|
|
770
|
+
"default",
|
|
771
|
+
"queryString",
|
|
772
|
+
"lazy"
|
|
773
|
+
]);
|
|
774
|
+
for (const key of [
|
|
775
|
+
"group",
|
|
776
|
+
"default",
|
|
777
|
+
"queryString"
|
|
778
|
+
]) if (attrs[key] !== void 0 && typeof attrs[key] !== "string") fail(path, `nonstring tab ${key}`);
|
|
779
|
+
if (attrs.lazy !== void 0 && typeof attrs.lazy !== "boolean") fail(path, "nonboolean tab lazy flag");
|
|
780
|
+
if (node.title) fail(path, "tab-group title");
|
|
781
|
+
return wrap("loomaTabs", {
|
|
782
|
+
group: attrs.group ?? "",
|
|
783
|
+
codeGroup: false,
|
|
784
|
+
...attrs.default !== void 0 ? { defaultValue: attrs.default } : {},
|
|
785
|
+
...attrs.queryString !== void 0 ? { queryString: attrs.queryString } : {},
|
|
786
|
+
...attrs.lazy !== void 0 ? { lazy: attrs.lazy } : {}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (node.subType === "tab") {
|
|
790
|
+
check(["value"]);
|
|
791
|
+
if (attrs.value !== void 0 && typeof attrs.value !== "string") fail(path, "nonstring tab value");
|
|
792
|
+
return wrap("loomaTab", {
|
|
793
|
+
label,
|
|
794
|
+
...attrs.value !== void 0 ? { value: attrs.value } : {}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (node.subType === "figure") {
|
|
798
|
+
check();
|
|
799
|
+
const only = node.body?.[0];
|
|
800
|
+
const image = only?.type === "paragraph" && !only.id && !only.attributes && content(only).length === 1 ? content(only)[0] : only;
|
|
801
|
+
if (node.body?.length !== 1 || image?.type !== "image") fail(path, "figure body other than one image");
|
|
802
|
+
return [{
|
|
803
|
+
type: "loomaFigure",
|
|
804
|
+
content: [...children(), {
|
|
805
|
+
type: "loomaFigcaption",
|
|
806
|
+
...typeof node.title === "object" && content(node.title).length ? { content: content(node.title).flatMap((child, index) => fromNode(child, `${path}.title[${index}]`)) } : {}
|
|
807
|
+
}]
|
|
808
|
+
}];
|
|
809
|
+
}
|
|
810
|
+
if (node.subType === "toc") {
|
|
811
|
+
check(["depth", "format"]);
|
|
812
|
+
if (node.title) fail(path, "table-of-contents title");
|
|
813
|
+
const depth = Number(attrs.depth ?? 3);
|
|
814
|
+
const format = attrs.format ?? "plain";
|
|
815
|
+
if (![
|
|
816
|
+
1,
|
|
817
|
+
2,
|
|
818
|
+
3
|
|
819
|
+
].includes(depth) || ![
|
|
820
|
+
"plain",
|
|
821
|
+
"bulleted",
|
|
822
|
+
"numbered"
|
|
823
|
+
].includes(String(format))) fail(path, "table-of-contents settings");
|
|
824
|
+
return wrap("loomaTableOfContents", {
|
|
825
|
+
depth,
|
|
826
|
+
format
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
return fail(path, `container type ${node.subType}`);
|
|
830
|
+
}
|
|
831
|
+
case "table": {
|
|
832
|
+
check();
|
|
833
|
+
if (node.title) fail(path, "table caption");
|
|
834
|
+
if (!node.align) return wrap("table");
|
|
835
|
+
let column = 0;
|
|
836
|
+
return [{
|
|
837
|
+
type: "table",
|
|
838
|
+
content: (node.children ?? []).map((row) => {
|
|
839
|
+
column = 0;
|
|
840
|
+
return {
|
|
841
|
+
...row,
|
|
842
|
+
children: row.children?.map((cell) => {
|
|
843
|
+
const align = node.align?.[column];
|
|
844
|
+
column += cell.colSpan ?? 1;
|
|
845
|
+
return {
|
|
846
|
+
...cell,
|
|
847
|
+
...align && !cell.attributes?.textAlign ? { attributes: {
|
|
848
|
+
...cell.attributes,
|
|
849
|
+
textAlign: align
|
|
850
|
+
} } : {}
|
|
851
|
+
};
|
|
852
|
+
})
|
|
853
|
+
};
|
|
854
|
+
}).flatMap((row, index) => fromNode(row, `${path}.children[${index}]`))
|
|
855
|
+
}];
|
|
856
|
+
}
|
|
857
|
+
case "tableRow":
|
|
858
|
+
check();
|
|
859
|
+
return wrap("tableRow");
|
|
860
|
+
case "tableCell": {
|
|
861
|
+
check([
|
|
862
|
+
"columnWidths",
|
|
863
|
+
"textAlign",
|
|
864
|
+
"backgroundColor"
|
|
865
|
+
]);
|
|
866
|
+
if (attrs.textAlign !== void 0 && ![
|
|
867
|
+
"left",
|
|
868
|
+
"center",
|
|
869
|
+
"right"
|
|
870
|
+
].includes(String(attrs.textAlign))) fail(path, "unsupported table alignment");
|
|
871
|
+
if (attrs.columnWidths !== void 0 && (typeof attrs.columnWidths !== "string" || !/^\d+(?:,\d+)*$/.test(attrs.columnWidths))) fail(path, "column widths");
|
|
872
|
+
const widths = typeof attrs.columnWidths === "string" ? attrs.columnWidths.split(",").map(Number) : null;
|
|
873
|
+
if (widths?.some((value) => !Number.isFinite(value) || value < 0)) fail(path, "column widths");
|
|
874
|
+
return [{
|
|
875
|
+
type: node.header ? "tableHeader" : "tableCell",
|
|
876
|
+
attrs: {
|
|
877
|
+
colspan: node.colSpan ?? 1,
|
|
878
|
+
rowspan: node.rowSpan ?? 1,
|
|
879
|
+
colwidth: widths,
|
|
880
|
+
textAlign: attrs.textAlign ?? null,
|
|
881
|
+
backgroundColor: attrs.backgroundColor ?? null
|
|
882
|
+
},
|
|
883
|
+
content: node.body ? children() : [{
|
|
884
|
+
type: "paragraph",
|
|
885
|
+
...content(node).length ? { content: inline() } : {}
|
|
886
|
+
}]
|
|
887
|
+
}];
|
|
888
|
+
}
|
|
889
|
+
default: return fail(path, `AST type ${node.type}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Maps supported Expressive Markdown content into the Looma editor's saved JSON shape.
|
|
894
|
+
* @contract The editor schema cannot represent arbitrary format extensions; unsupported meanings fail explicitly.
|
|
895
|
+
*/
|
|
896
|
+
function expressmarkToEditorDocument(document) {
|
|
897
|
+
if (document.type !== "document" || document.version !== 1) fail("document", "unsupported document version");
|
|
898
|
+
if (document.title || document.id || Object.keys(document.metadata ?? {}).length || Object.keys(document.attributes ?? {}).length) fail("document", "document metadata (store it alongside editor content)");
|
|
899
|
+
try {
|
|
900
|
+
stringify(document);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
fail("document", `invalid Expressive Markdown AST (${error instanceof Error ? error.message : "conversion failed"})`);
|
|
903
|
+
}
|
|
904
|
+
const colors = /* @__PURE__ */ new Set();
|
|
905
|
+
const urls = /* @__PURE__ */ new Set();
|
|
906
|
+
const visit = (node) => {
|
|
907
|
+
if (node.type === "tableCell" && node.attributes?.backgroundColor !== void 0 && !colors.has(node.attributes.backgroundColor)) {
|
|
908
|
+
safeBackground(node.attributes.backgroundColor, "document");
|
|
909
|
+
colors.add(node.attributes.backgroundColor);
|
|
910
|
+
}
|
|
911
|
+
if (node.type === "link" || node.type === "image") {
|
|
912
|
+
const key = `${node.type}:${node.url}`;
|
|
913
|
+
if (!urls.has(key)) {
|
|
914
|
+
safeNavigation(node.url, node.type === "image", "document");
|
|
915
|
+
urls.add(key);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
for (const child of node.body ?? node.children ?? []) visit(child);
|
|
919
|
+
if (typeof node.title === "object") visit(node.title);
|
|
920
|
+
};
|
|
921
|
+
visit(document);
|
|
922
|
+
return {
|
|
923
|
+
type: "doc",
|
|
924
|
+
content: document.body.flatMap((node, index) => fromNode(node, `document.body[${index}]`))
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Writes an editor document using Expressmark's shared parser/writer and safe HTML policy.
|
|
929
|
+
* @failure Unsupported editor data raises EditorMarkdownConversionError before any input is changed.
|
|
930
|
+
*/
|
|
931
|
+
function editorDocumentToMarkdown(document, options = {}) {
|
|
932
|
+
return stringify(editorDocumentToExpressmark(document, options), options);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Parses Expressive Markdown into editable JSON for the supported editor schema.
|
|
936
|
+
* @failure Unsupported content or root metadata stops conversion instead of being silently flattened.
|
|
937
|
+
*/
|
|
938
|
+
function markdownToEditorDocument(markdown, options = {}) {
|
|
939
|
+
return expressmarkToEditorDocument(parse(markdown, options));
|
|
940
|
+
}
|
|
941
|
+
//#endregion
|
|
942
|
+
export { EditorMarkdownConversionError, editorDocumentToExpressmark, editorDocumentToMarkdown, expressmarkToEditorDocument, markdownToEditorDocument };
|