@jxsuite/parser 0.26.2 → 0.28.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/content.js +53 -1
- package/dist/csv.js +189 -0
- package/dist/markdown.js +44815 -0
- package/dist/serialize.js +30786 -0
- package/package.json +14 -7
- package/src/Csv.class.json +171 -0
- package/src/Markdown.class.json +272 -0
- package/src/content.ts +17 -4
- package/src/csv.ts +209 -0
- package/src/markdown.ts +112 -0
- package/src/md.ts +6 -49
- package/src/serialize.ts +1482 -0
- package/src/MarkdownFile.class.json +0 -94
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jx Markdown Serializer — Browser-safe module
|
|
3
|
+
*
|
|
4
|
+
* The single Jx → markdown serializer, replacing the studio's md-convert and the
|
|
5
|
+
* compiler's compile-markdown. Two modes over a shared tag map:
|
|
6
|
+
*
|
|
7
|
+
* - `roundtrip` (default) — lossless: YAML frontmatter from non-children doc keys,
|
|
8
|
+
* non-markdown elements emitted as remark directives with collapsed dot-path
|
|
9
|
+
* attributes. Inverse of `transpileJxMarkdown()`.
|
|
10
|
+
* - `export` — lossy: strips all Jx decoration, unwraps wrapper tags, inlines custom
|
|
11
|
+
* elements via injected component definitions, evaluates template strings via
|
|
12
|
+
* injected hooks. Produces clean GFM with no directives or frontmatter.
|
|
13
|
+
*
|
|
14
|
+
* Template evaluation is injected (the compiler passes its static-template evaluator)
|
|
15
|
+
* so this module stays free of compiler dependencies and browser-safe.
|
|
16
|
+
*
|
|
17
|
+
* @module @jxsuite/parser/serialize
|
|
18
|
+
* @license MIT
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { unified } from "unified";
|
|
22
|
+
import remarkStringify from "remark-stringify";
|
|
23
|
+
import remarkDirective from "remark-directive";
|
|
24
|
+
import remarkGfm from "remark-gfm";
|
|
25
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
26
|
+
import { htmlToJx } from "./html-to-jx.ts";
|
|
27
|
+
import type {
|
|
28
|
+
JxElement,
|
|
29
|
+
JxDocument,
|
|
30
|
+
JxStateDefinition,
|
|
31
|
+
JxMutableNode,
|
|
32
|
+
} from "@jxsuite/schema/types";
|
|
33
|
+
import type { MdastNode } from "./types.ts";
|
|
34
|
+
|
|
35
|
+
// ─── Markdown element sets ──────────────────────────────────────────────────
|
|
36
|
+
// Source of truth for the `$studio.elements` block in Markdown.class.json — a parser
|
|
37
|
+
// test asserts the two stay in sync.
|
|
38
|
+
|
|
39
|
+
export const MD_ELEMENTS = {
|
|
40
|
+
block: [
|
|
41
|
+
"h1",
|
|
42
|
+
"h2",
|
|
43
|
+
"h3",
|
|
44
|
+
"h4",
|
|
45
|
+
"h5",
|
|
46
|
+
"h6",
|
|
47
|
+
"p",
|
|
48
|
+
"blockquote",
|
|
49
|
+
"ul",
|
|
50
|
+
"ol",
|
|
51
|
+
"li",
|
|
52
|
+
"pre",
|
|
53
|
+
"hr",
|
|
54
|
+
"table",
|
|
55
|
+
"thead",
|
|
56
|
+
"tbody",
|
|
57
|
+
"tr",
|
|
58
|
+
"th",
|
|
59
|
+
"td",
|
|
60
|
+
],
|
|
61
|
+
inline: ["em", "strong", "del", "code", "a", "img", "br"],
|
|
62
|
+
void: ["hr", "br", "img"],
|
|
63
|
+
textOnly: ["code"],
|
|
64
|
+
nesting: {
|
|
65
|
+
_root: { block: true, inline: false, directive: true },
|
|
66
|
+
h1: { block: false, inline: true, directive: false },
|
|
67
|
+
h2: { block: false, inline: true, directive: false },
|
|
68
|
+
h3: { block: false, inline: true, directive: false },
|
|
69
|
+
h4: { block: false, inline: true, directive: false },
|
|
70
|
+
h5: { block: false, inline: true, directive: false },
|
|
71
|
+
h6: { block: false, inline: true, directive: false },
|
|
72
|
+
p: { block: false, inline: true, directive: true },
|
|
73
|
+
blockquote: { block: true, inline: false, directive: true },
|
|
74
|
+
ul: { only: ["li"] },
|
|
75
|
+
ol: { only: ["li"] },
|
|
76
|
+
li: { block: true, inline: true, directive: true },
|
|
77
|
+
pre: { only: ["code"] },
|
|
78
|
+
table: { only: ["thead", "tbody"] },
|
|
79
|
+
thead: { only: ["tr"] },
|
|
80
|
+
tbody: { only: ["tr"] },
|
|
81
|
+
tr: { only: ["th", "td"] },
|
|
82
|
+
th: { block: false, inline: true, directive: false },
|
|
83
|
+
td: { block: false, inline: true, directive: false },
|
|
84
|
+
em: { block: false, inline: true, directive: false },
|
|
85
|
+
strong: { block: false, inline: true, directive: false },
|
|
86
|
+
del: { block: false, inline: true, directive: false },
|
|
87
|
+
a: { block: false, inline: true, directive: false },
|
|
88
|
+
} as Record<string, { block?: boolean; inline?: boolean; directive?: boolean; only?: string[] }>,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** Markdown-native block tags. */
|
|
92
|
+
export const MD_BLOCK: ReadonlySet<string> = new Set(MD_ELEMENTS.block);
|
|
93
|
+
/** Markdown-native inline tags. */
|
|
94
|
+
export const MD_INLINE: ReadonlySet<string> = new Set(MD_ELEMENTS.inline);
|
|
95
|
+
/** All markdown-native tags — everything else serializes as a directive. */
|
|
96
|
+
export const MD_ALL: ReadonlySet<string> = new Set([...MD_ELEMENTS.block, ...MD_ELEMENTS.inline]);
|
|
97
|
+
/** Tags that cannot contain children. */
|
|
98
|
+
export const MD_VOID: ReadonlySet<string> = new Set(MD_ELEMENTS.void);
|
|
99
|
+
/** Tags that contain only text. */
|
|
100
|
+
export const MD_TEXT_ONLY: ReadonlySet<string> = new Set(MD_ELEMENTS.textOnly);
|
|
101
|
+
|
|
102
|
+
// ─── Shared tag maps ────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Jx tagName → mdast node-type. */
|
|
105
|
+
const TAG_MDAST_MAP: Record<string, string> = {
|
|
106
|
+
h1: "heading",
|
|
107
|
+
h2: "heading",
|
|
108
|
+
h3: "heading",
|
|
109
|
+
h4: "heading",
|
|
110
|
+
h5: "heading",
|
|
111
|
+
h6: "heading",
|
|
112
|
+
p: "paragraph",
|
|
113
|
+
span: "text",
|
|
114
|
+
em: "emphasis",
|
|
115
|
+
strong: "strong",
|
|
116
|
+
del: "delete",
|
|
117
|
+
code: "inlineCode",
|
|
118
|
+
a: "link",
|
|
119
|
+
img: "image",
|
|
120
|
+
blockquote: "blockquote",
|
|
121
|
+
ul: "list",
|
|
122
|
+
ol: "list",
|
|
123
|
+
li: "listItem",
|
|
124
|
+
pre: "code",
|
|
125
|
+
hr: "thematicBreak",
|
|
126
|
+
br: "break",
|
|
127
|
+
table: "table",
|
|
128
|
+
thead: "thead",
|
|
129
|
+
tbody: "tbody",
|
|
130
|
+
tr: "tableRow",
|
|
131
|
+
th: "tableCell",
|
|
132
|
+
td: "tableCell",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Wrapper tags unwrapped in export mode — their children are promoted. */
|
|
136
|
+
const WRAPPER_TAGS = new Set([
|
|
137
|
+
"div",
|
|
138
|
+
"section",
|
|
139
|
+
"span",
|
|
140
|
+
"nav",
|
|
141
|
+
"header",
|
|
142
|
+
"footer",
|
|
143
|
+
"main",
|
|
144
|
+
"article",
|
|
145
|
+
"aside",
|
|
146
|
+
"figure",
|
|
147
|
+
"figcaption",
|
|
148
|
+
"slot",
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/** Tags whose content model is inline (phrasing content). */
|
|
152
|
+
const INLINE_CONTENT_TAGS = new Set([
|
|
153
|
+
"p",
|
|
154
|
+
"h1",
|
|
155
|
+
"h2",
|
|
156
|
+
"h3",
|
|
157
|
+
"h4",
|
|
158
|
+
"h5",
|
|
159
|
+
"h6",
|
|
160
|
+
"em",
|
|
161
|
+
"strong",
|
|
162
|
+
"del",
|
|
163
|
+
"a",
|
|
164
|
+
"td",
|
|
165
|
+
"th",
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
function isInlineType(type: string) {
|
|
169
|
+
return ["text", "emphasis", "strong", "delete", "inlineCode", "link", "image", "break"].includes(
|
|
170
|
+
type,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a fenced-code child's language. Canonical: `className`; legacy: `attributes.class`. */
|
|
175
|
+
function codeLang(codeChild: JxElement | undefined): string | null {
|
|
176
|
+
const cls = codeChild?.className ?? (codeChild?.attributes?.class as string | undefined) ?? "";
|
|
177
|
+
const lang = String(cls).replace("language-", "");
|
|
178
|
+
return lang || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface SerializeOptions {
|
|
184
|
+
/** `roundtrip` (lossless, default) or `export` (lossy clean markdown). */
|
|
185
|
+
mode?: "roundtrip" | "export";
|
|
186
|
+
/** Export mode: component definitions for inlining custom elements. */
|
|
187
|
+
componentDefs?: Map<string, JxElement>;
|
|
188
|
+
/**
|
|
189
|
+
* Export mode: evaluate a template string against a scope. Return `undefined` to keep the
|
|
190
|
+
* original string. Hosts inject their own evaluator (the compiler passes its static-template
|
|
191
|
+
* machinery); default keeps templates verbatim.
|
|
192
|
+
*/
|
|
193
|
+
evaluateTemplate?: (value: string, scope: Record<string, unknown>) => unknown;
|
|
194
|
+
/** Export mode: build an evaluation scope from state definitions. */
|
|
195
|
+
buildScope?: (state: Record<string, JxStateDefinition>) => Record<string, unknown> | null;
|
|
196
|
+
/** Roundtrip mode: markdown-native tag set; anything else becomes a directive. */
|
|
197
|
+
allowlist?: ReadonlySet<string> | string[];
|
|
198
|
+
/** Roundtrip mode: emit YAML frontmatter from non-children doc keys (default true). */
|
|
199
|
+
frontmatter?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
// Roundtrip mode — mdast ↔ Jx (lossless, directive fallback)
|
|
204
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
205
|
+
|
|
206
|
+
/** Mdast node-type → Jx tagName mapping (canvas-targeted; text → span). */
|
|
207
|
+
const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
|
|
208
|
+
heading: (n) => `h${n.depth}`,
|
|
209
|
+
paragraph: () => "p",
|
|
210
|
+
text: () => "span",
|
|
211
|
+
emphasis: () => "em",
|
|
212
|
+
strong: () => "strong",
|
|
213
|
+
delete: () => "del",
|
|
214
|
+
inlineCode: () => "code",
|
|
215
|
+
link: () => "a",
|
|
216
|
+
image: () => "img",
|
|
217
|
+
blockquote: () => "blockquote",
|
|
218
|
+
list: (n) => (n.ordered ? "ol" : "ul"),
|
|
219
|
+
listItem: () => "li",
|
|
220
|
+
code: () => "pre",
|
|
221
|
+
thematicBreak: () => "hr",
|
|
222
|
+
table: () => "table",
|
|
223
|
+
tableRow: () => "tr",
|
|
224
|
+
tableCell: (n) => (n.isHeader ? "th" : "td"),
|
|
225
|
+
break: () => "br",
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Convert an mdast tree to a Jx element tree (canvas-targeted: text nodes become spans). Inverse of
|
|
230
|
+
* {@link jxToMdast}.
|
|
231
|
+
*/
|
|
232
|
+
export function mdastToJx(mdast: MdastNode): JxElement {
|
|
233
|
+
if (mdast.type === "root") {
|
|
234
|
+
return {
|
|
235
|
+
children: (mdast.children ?? [])
|
|
236
|
+
.filter((n) => n.type !== "yaml" && n.type !== "toml")
|
|
237
|
+
.flatMap(convertMdastNode)
|
|
238
|
+
.filter(Boolean) as (JxElement | string)[],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return convertMdastNode(mdast) as JxElement;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function convertMdastNode(node: MdastNode): JxElement | null {
|
|
245
|
+
if (!node) return null;
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
node.type === "containerDirective" ||
|
|
249
|
+
node.type === "leafDirective" ||
|
|
250
|
+
node.type === "textDirective"
|
|
251
|
+
) {
|
|
252
|
+
return convertDirective(node);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (node.type === "html") {
|
|
256
|
+
if (!node.value) return null;
|
|
257
|
+
const nodes = htmlToJx(node.value);
|
|
258
|
+
return nodes.length === 1 ? (nodes[0] as JxElement) : { tagName: "div", children: nodes };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tagFn = MDAST_TAG_MAP[node.type];
|
|
262
|
+
if (!tagFn) return null;
|
|
263
|
+
|
|
264
|
+
const tag = tagFn(node);
|
|
265
|
+
const el: JxElement = { tagName: tag };
|
|
266
|
+
|
|
267
|
+
const childNodes = () =>
|
|
268
|
+
(node.children ?? []).flatMap(convertMdastNode).filter(Boolean) as (JxElement | string)[];
|
|
269
|
+
const flattenOrChildren = () => {
|
|
270
|
+
if (node.children?.length === 1 && node.children[0].type === "text") {
|
|
271
|
+
el.textContent = node.children[0].value ?? null;
|
|
272
|
+
} else if (node.children?.length) {
|
|
273
|
+
el.children = childNodes();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
switch (node.type) {
|
|
278
|
+
case "heading":
|
|
279
|
+
case "paragraph":
|
|
280
|
+
case "emphasis":
|
|
281
|
+
case "strong":
|
|
282
|
+
case "delete":
|
|
283
|
+
case "tableCell":
|
|
284
|
+
flattenOrChildren();
|
|
285
|
+
break;
|
|
286
|
+
|
|
287
|
+
case "text":
|
|
288
|
+
case "inlineCode":
|
|
289
|
+
el.textContent = node.value ?? null;
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
case "link":
|
|
293
|
+
el.attributes = { href: node.url ?? "" };
|
|
294
|
+
if (node.title) el.attributes.title = node.title;
|
|
295
|
+
flattenOrChildren();
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case "image":
|
|
299
|
+
el.attributes = { src: node.url ?? "", alt: node.alt ?? "" };
|
|
300
|
+
if (node.title) el.attributes.title = node.title;
|
|
301
|
+
break;
|
|
302
|
+
|
|
303
|
+
case "blockquote":
|
|
304
|
+
case "listItem":
|
|
305
|
+
case "tableRow":
|
|
306
|
+
if (node.children?.length) el.children = childNodes();
|
|
307
|
+
break;
|
|
308
|
+
|
|
309
|
+
case "list":
|
|
310
|
+
if (node.children?.length) el.children = childNodes();
|
|
311
|
+
if (node.start != null && node.start !== 1) {
|
|
312
|
+
el.attributes = { start: String(node.start) };
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
|
|
316
|
+
case "code":
|
|
317
|
+
el.children = [
|
|
318
|
+
{
|
|
319
|
+
tagName: "code",
|
|
320
|
+
textContent: node.value ?? null,
|
|
321
|
+
...(node.lang ? { className: `language-${node.lang}` } : {}),
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
break;
|
|
325
|
+
|
|
326
|
+
case "thematicBreak":
|
|
327
|
+
case "break":
|
|
328
|
+
break;
|
|
329
|
+
|
|
330
|
+
case "table": {
|
|
331
|
+
const rows = childNodes() as JxElement[];
|
|
332
|
+
const thead =
|
|
333
|
+
rows.length > 0
|
|
334
|
+
? { tagName: "thead", children: [rows[0]] as (JxElement | string)[] }
|
|
335
|
+
: null;
|
|
336
|
+
const tbody =
|
|
337
|
+
rows.length > 1
|
|
338
|
+
? {
|
|
339
|
+
tagName: "tbody",
|
|
340
|
+
children: rows.slice(1) as (JxElement | string)[],
|
|
341
|
+
}
|
|
342
|
+
: null;
|
|
343
|
+
el.children = [thead, tbody].filter(Boolean) as (JxElement | string)[];
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return el;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function convertDirective(node: MdastNode): JxElement {
|
|
352
|
+
const el: JxElement = { tagName: node.name ?? "div" };
|
|
353
|
+
if (node.attributes && Object.keys(node.attributes).length > 0) {
|
|
354
|
+
el.attributes = { ...node.attributes };
|
|
355
|
+
}
|
|
356
|
+
if (node.type === "textDirective") {
|
|
357
|
+
if (node.children?.length === 1 && node.children[0].type === "text") {
|
|
358
|
+
el.textContent = node.children[0].value ?? null;
|
|
359
|
+
} else if (node.children?.length) {
|
|
360
|
+
el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
|
|
361
|
+
| JxElement
|
|
362
|
+
| string
|
|
363
|
+
)[];
|
|
364
|
+
}
|
|
365
|
+
} else if (node.type === "containerDirective" && node.children?.length) {
|
|
366
|
+
el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (JxElement | string)[];
|
|
367
|
+
}
|
|
368
|
+
return el;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── Roundtrip: Jx → mdast ───────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Convert a Jx element tree to an mdast tree (lossless: non-markdown elements become directives).
|
|
375
|
+
* Inverse of {@link mdastToJx}.
|
|
376
|
+
*/
|
|
377
|
+
export function jxToMdast(jx: JxElement, opts: SerializeOptions = {}): MdastNode {
|
|
378
|
+
const allowlist = normalizeAllowlist(opts.allowlist);
|
|
379
|
+
const childArray = Array.isArray(jx.children) ? jx.children : ([] as (JxElement | string)[]);
|
|
380
|
+
const children = childArray
|
|
381
|
+
.map((child) => convertJxNode(child, true, allowlist))
|
|
382
|
+
.filter(Boolean) as MdastNode[];
|
|
383
|
+
|
|
384
|
+
return { type: "root", children };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeAllowlist(allowlist?: ReadonlySet<string> | string[]): ReadonlySet<string> {
|
|
388
|
+
if (!allowlist) return MD_ALL;
|
|
389
|
+
return allowlist instanceof Set ? allowlist : new Set(allowlist);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Check if a Jx element has properties beyond the mdast-compatible ones. */
|
|
393
|
+
function hasJxProps(el: JxElement) {
|
|
394
|
+
for (const key of Object.keys(el)) {
|
|
395
|
+
if (
|
|
396
|
+
key === "tagName" ||
|
|
397
|
+
key === "children" ||
|
|
398
|
+
key === "textContent" ||
|
|
399
|
+
key === "innerHTML" ||
|
|
400
|
+
key === "attributes"
|
|
401
|
+
)
|
|
402
|
+
continue;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function convertJxNode(
|
|
409
|
+
el: JxElement | string | number,
|
|
410
|
+
isBlock: boolean,
|
|
411
|
+
allowlist: ReadonlySet<string>,
|
|
412
|
+
): MdastNode | null {
|
|
413
|
+
if (typeof el === "string" || typeof el === "number") {
|
|
414
|
+
return { type: "text", value: String(el) };
|
|
415
|
+
}
|
|
416
|
+
if (!el || typeof el !== "object") return null;
|
|
417
|
+
|
|
418
|
+
const tag = el.tagName ?? "div";
|
|
419
|
+
|
|
420
|
+
// Not markdown-native or carries Jx-specific props → directive
|
|
421
|
+
if (!allowlist.has(tag) || hasJxProps(el)) {
|
|
422
|
+
return convertToDirective(el, isBlock, allowlist);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const mdastType = TAG_MDAST_MAP[tag];
|
|
426
|
+
if (!mdastType) return null;
|
|
427
|
+
|
|
428
|
+
const inline = (e: JxElement): MdastNode[] => {
|
|
429
|
+
if (e.textContent != null) return [{ type: "text", value: String(e.textContent) }];
|
|
430
|
+
return ((e.children ?? []) as (JxElement | string)[])
|
|
431
|
+
.map((c) => convertJxNode(c, false, allowlist))
|
|
432
|
+
.filter(Boolean) as MdastNode[];
|
|
433
|
+
};
|
|
434
|
+
const block = (e: JxElement): MdastNode[] => {
|
|
435
|
+
if (e.textContent != null) {
|
|
436
|
+
return [
|
|
437
|
+
{
|
|
438
|
+
type: "paragraph",
|
|
439
|
+
children: [{ type: "text", value: String(e.textContent) }],
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
return ((e.children ?? []) as (JxElement | string)[])
|
|
444
|
+
.map((c) => convertJxNode(c, true, allowlist))
|
|
445
|
+
.filter(Boolean) as MdastNode[];
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
switch (mdastType) {
|
|
449
|
+
case "heading":
|
|
450
|
+
return {
|
|
451
|
+
type: "heading",
|
|
452
|
+
depth: parseInt(tag.slice(1), 10),
|
|
453
|
+
children: inline(el),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
case "paragraph":
|
|
457
|
+
return { type: "paragraph", children: inline(el) };
|
|
458
|
+
|
|
459
|
+
case "text":
|
|
460
|
+
return { type: "text", value: el.textContent ?? "" };
|
|
461
|
+
|
|
462
|
+
case "emphasis":
|
|
463
|
+
case "strong":
|
|
464
|
+
case "delete":
|
|
465
|
+
return { type: mdastType, children: inline(el) };
|
|
466
|
+
|
|
467
|
+
case "inlineCode":
|
|
468
|
+
return { type: "inlineCode", value: el.textContent ?? "" };
|
|
469
|
+
|
|
470
|
+
case "link":
|
|
471
|
+
return {
|
|
472
|
+
type: "link",
|
|
473
|
+
url: (el.attributes?.href as string) ?? "",
|
|
474
|
+
title: (el.attributes?.title as string | null) ?? null,
|
|
475
|
+
children: inline(el),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
case "image":
|
|
479
|
+
return {
|
|
480
|
+
type: "image",
|
|
481
|
+
url: (el.attributes?.src as string) ?? "",
|
|
482
|
+
alt: (el.attributes?.alt as string) ?? "",
|
|
483
|
+
title: (el.attributes?.title as string | null) ?? null,
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
case "blockquote":
|
|
487
|
+
return { type: "blockquote", children: block(el) };
|
|
488
|
+
|
|
489
|
+
case "list":
|
|
490
|
+
return {
|
|
491
|
+
type: "list",
|
|
492
|
+
ordered: tag === "ol",
|
|
493
|
+
start: tag === "ol" ? parseInt(el.attributes?.start as string, 10) || 1 : null,
|
|
494
|
+
spread: false,
|
|
495
|
+
children: ((el.children ?? []) as (JxElement | string)[])
|
|
496
|
+
.map((c) => convertJxNode(c, true, allowlist))
|
|
497
|
+
.filter(Boolean) as MdastNode[],
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
case "listItem":
|
|
501
|
+
return { type: "listItem", spread: false, children: block(el) };
|
|
502
|
+
|
|
503
|
+
case "code": {
|
|
504
|
+
const codeChild = Array.isArray(el.children)
|
|
505
|
+
? (el.children[0] as JxElement | undefined)
|
|
506
|
+
: undefined;
|
|
507
|
+
return {
|
|
508
|
+
type: "code",
|
|
509
|
+
lang: codeLang(codeChild),
|
|
510
|
+
value: codeChild?.textContent ?? el.textContent ?? "",
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
case "thematicBreak":
|
|
515
|
+
return { type: "thematicBreak" };
|
|
516
|
+
|
|
517
|
+
case "break":
|
|
518
|
+
return { type: "break" };
|
|
519
|
+
|
|
520
|
+
case "table": {
|
|
521
|
+
// Flatten thead/tbody back to rows
|
|
522
|
+
const rows: MdastNode[] = [];
|
|
523
|
+
for (const section of (el.children ?? []) as (JxElement | string)[]) {
|
|
524
|
+
if (typeof section === "string") continue;
|
|
525
|
+
if (section.tagName === "thead" || section.tagName === "tbody") {
|
|
526
|
+
for (const row of (section.children ?? []) as (JxElement | string)[]) {
|
|
527
|
+
const mdRow = convertJxNode(row, true, allowlist);
|
|
528
|
+
if (mdRow) {
|
|
529
|
+
if (section.tagName === "thead") {
|
|
530
|
+
for (const cell of mdRow.children ?? []) cell.isHeader = true;
|
|
531
|
+
}
|
|
532
|
+
rows.push(mdRow);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return { type: "table", children: rows };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
case "tableRow":
|
|
541
|
+
return {
|
|
542
|
+
type: "tableRow",
|
|
543
|
+
children: ((el.children ?? []) as (JxElement | string)[])
|
|
544
|
+
.map((c) => convertJxNode(c, false, allowlist))
|
|
545
|
+
.filter(Boolean) as MdastNode[],
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
case "tableCell":
|
|
549
|
+
return { type: "tableCell", children: inline(el) };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Roundtrip: directive emission ───────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/** CSS pseudo-class names that need `:` stripped for markdown attributes. */
|
|
558
|
+
const CSS_PSEUDO_NAMES = new Set([
|
|
559
|
+
"hover",
|
|
560
|
+
"focus",
|
|
561
|
+
"active",
|
|
562
|
+
"visited",
|
|
563
|
+
"disabled",
|
|
564
|
+
"checked",
|
|
565
|
+
"valid",
|
|
566
|
+
"invalid",
|
|
567
|
+
"required",
|
|
568
|
+
"empty",
|
|
569
|
+
"first-child",
|
|
570
|
+
"last-child",
|
|
571
|
+
"focus-within",
|
|
572
|
+
"focus-visible",
|
|
573
|
+
"placeholder",
|
|
574
|
+
"selection",
|
|
575
|
+
"before",
|
|
576
|
+
"after",
|
|
577
|
+
]);
|
|
578
|
+
|
|
579
|
+
/** Jx `$`-prefixed keys that become unprefixed in directive attributes. */
|
|
580
|
+
const JX_DOLLAR_KEYS = new Set([
|
|
581
|
+
"$prototype",
|
|
582
|
+
"$ref",
|
|
583
|
+
"$component",
|
|
584
|
+
"$props",
|
|
585
|
+
"$switch",
|
|
586
|
+
"$elements",
|
|
587
|
+
]);
|
|
588
|
+
|
|
589
|
+
const JX_ANNOTATION_KEYS = new Set(["$title", "$description"]);
|
|
590
|
+
|
|
591
|
+
function collectDirectiveAttrs(el: JxElement) {
|
|
592
|
+
const propsObj: Record<string, unknown> = {};
|
|
593
|
+
|
|
594
|
+
for (const [key, value] of Object.entries(el)) {
|
|
595
|
+
if (key === "tagName" || key === "textContent" || key === "innerHTML" || key === "attributes")
|
|
596
|
+
continue;
|
|
597
|
+
if (key === "children") {
|
|
598
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
599
|
+
propsObj.children = value;
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
propsObj[key] = value;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (el.attributes) {
|
|
607
|
+
for (const [key, value] of Object.entries(el.attributes as Record<string, unknown>)) {
|
|
608
|
+
propsObj[key] = value;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return collapsePropsToAttrMap(propsObj);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function convertToDirective(
|
|
616
|
+
el: JxElement,
|
|
617
|
+
isBlock: boolean,
|
|
618
|
+
allowlist: ReadonlySet<string>,
|
|
619
|
+
): MdastNode {
|
|
620
|
+
const tag = (el.tagName as string) ?? "div";
|
|
621
|
+
const attrs = collectDirectiveAttrs(el);
|
|
622
|
+
|
|
623
|
+
if (!isBlock) {
|
|
624
|
+
return {
|
|
625
|
+
type: "textDirective",
|
|
626
|
+
name: tag,
|
|
627
|
+
attributes: attrs,
|
|
628
|
+
children:
|
|
629
|
+
el.textContent != null
|
|
630
|
+
? [{ type: "text", value: String(el.textContent) }]
|
|
631
|
+
: (((el.children ?? []) as (JxElement | string)[])
|
|
632
|
+
.map((c) => convertJxNode(c, false, allowlist))
|
|
633
|
+
.filter(Boolean) as MdastNode[]),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const rawChildren = el.children;
|
|
638
|
+
const childrenIsObject =
|
|
639
|
+
rawChildren && typeof rawChildren === "object" && !Array.isArray(rawChildren);
|
|
640
|
+
const childArray = childrenIsObject
|
|
641
|
+
? undefined
|
|
642
|
+
: (rawChildren as (JxElement | string)[] | undefined);
|
|
643
|
+
if (!childArray?.length && el.textContent == null) {
|
|
644
|
+
return {
|
|
645
|
+
type: "leafDirective",
|
|
646
|
+
name: tag,
|
|
647
|
+
attributes: attrs,
|
|
648
|
+
children: [],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let directiveChildren: MdastNode[];
|
|
653
|
+
if (el.textContent != null) {
|
|
654
|
+
directiveChildren = [
|
|
655
|
+
{
|
|
656
|
+
type: "paragraph",
|
|
657
|
+
children: [{ type: "text", value: String(el.textContent) }],
|
|
658
|
+
},
|
|
659
|
+
];
|
|
660
|
+
} else if (INLINE_CONTENT_TAGS.has(tag)) {
|
|
661
|
+
// Inline content model: wrap children in one paragraph for continuous flow
|
|
662
|
+
const inlineNodes = ((el.children ?? []) as (JxElement | string)[])
|
|
663
|
+
.map((c) => convertJxNode(c, false, allowlist))
|
|
664
|
+
.filter(Boolean) as MdastNode[];
|
|
665
|
+
directiveChildren =
|
|
666
|
+
inlineNodes.length > 0 ? [{ type: "paragraph", children: inlineNodes }] : [];
|
|
667
|
+
} else {
|
|
668
|
+
directiveChildren = ((el.children ?? []) as (JxElement | string)[])
|
|
669
|
+
.map((c) => convertJxNode(c, true, allowlist))
|
|
670
|
+
.filter(Boolean) as MdastNode[];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
type: "containerDirective",
|
|
675
|
+
name: tag,
|
|
676
|
+
attributes: attrs,
|
|
677
|
+
children: directiveChildren,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
|
|
682
|
+
const result: Record<string, string> = {};
|
|
683
|
+
|
|
684
|
+
function walk(obj: Record<string, unknown>, prefix: string) {
|
|
685
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
686
|
+
let mdAttrKey = key;
|
|
687
|
+
if (JX_DOLLAR_KEYS.has(key)) {
|
|
688
|
+
mdAttrKey = key.slice(1);
|
|
689
|
+
}
|
|
690
|
+
if (JX_ANNOTATION_KEYS.has(key)) {
|
|
691
|
+
mdAttrKey = `--${key.slice(1)}`;
|
|
692
|
+
}
|
|
693
|
+
if (key.startsWith(":") && CSS_PSEUDO_NAMES.has(key.slice(1))) {
|
|
694
|
+
mdAttrKey = key.slice(1);
|
|
695
|
+
}
|
|
696
|
+
if (key === "@") {
|
|
697
|
+
// Bare "@" (no media name) — treat contents as base-level style props
|
|
698
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
699
|
+
walk(value as Record<string, unknown>, prefix);
|
|
700
|
+
}
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (key.startsWith("@--")) {
|
|
704
|
+
mdAttrKey = key.slice(1);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const fullKey = prefix ? `${prefix}.${mdAttrKey}` : mdAttrKey;
|
|
708
|
+
|
|
709
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
710
|
+
walk(value as Record<string, unknown>, fullKey);
|
|
711
|
+
} else {
|
|
712
|
+
result[fullKey] = String(value);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
walk(propsObj, "");
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
722
|
+
// Export mode — clean markdown (lossy, no directives)
|
|
723
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
724
|
+
|
|
725
|
+
interface ExportContext {
|
|
726
|
+
componentDefs: Map<string, JxElement>;
|
|
727
|
+
evaluateTemplate?: SerializeOptions["evaluateTemplate"];
|
|
728
|
+
buildScope?: SerializeOptions["buildScope"];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function nodeToMdast(
|
|
732
|
+
node: JxElement | string,
|
|
733
|
+
ctx: ExportContext,
|
|
734
|
+
scope?: Record<string, unknown> | null,
|
|
735
|
+
): MdastNode[] {
|
|
736
|
+
if (typeof node === "string") {
|
|
737
|
+
return node.trim() ? [{ type: "text", value: node }] : [];
|
|
738
|
+
}
|
|
739
|
+
if (typeof node === "number") {
|
|
740
|
+
return [{ type: "text", value: String(node) }];
|
|
741
|
+
}
|
|
742
|
+
if (!node || typeof node !== "object") return [];
|
|
743
|
+
|
|
744
|
+
// Array descriptor — expand mapped arrays
|
|
745
|
+
if (node.$prototype === "Array") {
|
|
746
|
+
return expandArray(node, ctx, scope);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const tag = node.tagName ?? "div";
|
|
750
|
+
const text = resolveText(node.textContent, ctx, scope);
|
|
751
|
+
|
|
752
|
+
// innerHTML — convert HTML content to mdast
|
|
753
|
+
if (typeof node.innerHTML === "string" && node.innerHTML.trim()) {
|
|
754
|
+
const htmlNodes = htmlToMdast(node.innerHTML);
|
|
755
|
+
if (htmlNodes.length > 0) return htmlNodes;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Custom elements — inline component content
|
|
759
|
+
if (tag.includes("-")) {
|
|
760
|
+
return inlineComponent(node, tag, ctx);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Wrapper tags — unwrap, promote children
|
|
764
|
+
if (WRAPPER_TAGS.has(tag)) {
|
|
765
|
+
if (text != null) {
|
|
766
|
+
return text.trim() ? [{ type: "paragraph", children: [{ type: "text", value: text }] }] : [];
|
|
767
|
+
}
|
|
768
|
+
return exportChildren(node, ctx, scope);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const mdastType = TAG_MDAST_MAP[tag];
|
|
772
|
+
if (!mdastType) {
|
|
773
|
+
if (text != null) {
|
|
774
|
+
return text.trim() ? [{ type: "paragraph", children: [{ type: "text", value: text }] }] : [];
|
|
775
|
+
}
|
|
776
|
+
return exportChildren(node, ctx, scope);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
switch (mdastType) {
|
|
780
|
+
case "heading": {
|
|
781
|
+
const depth = parseInt(tag.slice(1), 10);
|
|
782
|
+
const children =
|
|
783
|
+
text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
|
|
784
|
+
return [{ type: "heading", depth, children }];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
case "paragraph": {
|
|
788
|
+
const children =
|
|
789
|
+
text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
|
|
790
|
+
if (children.length === 0) return [];
|
|
791
|
+
return [{ type: "paragraph", children }];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
case "emphasis":
|
|
795
|
+
case "strong":
|
|
796
|
+
case "delete": {
|
|
797
|
+
const children =
|
|
798
|
+
text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
|
|
799
|
+
return [{ type: mdastType, children }];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
case "inlineCode":
|
|
803
|
+
return [{ type: "inlineCode", value: text ?? "" }];
|
|
804
|
+
|
|
805
|
+
case "link": {
|
|
806
|
+
const href = String(node.attributes?.href ?? "");
|
|
807
|
+
const title = (node.attributes?.title as string | null) ?? null;
|
|
808
|
+
const children =
|
|
809
|
+
text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
|
|
810
|
+
return [{ type: "link", url: href, title, children }];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
case "image": {
|
|
814
|
+
const src = String(node.attributes?.src ?? "");
|
|
815
|
+
const alt = String(node.attributes?.alt ?? "");
|
|
816
|
+
const title = (node.attributes?.title as string | null) ?? null;
|
|
817
|
+
return [{ type: "image", url: src, alt, title }];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
case "blockquote": {
|
|
821
|
+
const children = exportChildren(node, ctx, scope);
|
|
822
|
+
const wrapped = children.map((c) =>
|
|
823
|
+
c.type === "text" ? { type: "paragraph", children: [c] } : c,
|
|
824
|
+
);
|
|
825
|
+
return [{ type: "blockquote", children: wrapped }];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
case "list": {
|
|
829
|
+
const ordered = tag === "ol";
|
|
830
|
+
const children = exportChildren(node, ctx, scope);
|
|
831
|
+
const items = children.filter((c) => c.type === "listItem");
|
|
832
|
+
if (items.length === 0) return [];
|
|
833
|
+
return [{ type: "list", ordered, spread: false, children: items }];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
case "listItem": {
|
|
837
|
+
let children = exportChildren(node, ctx, scope);
|
|
838
|
+
if (children.length > 0 && children.every((c) => c.type === "text" || isInlineType(c.type))) {
|
|
839
|
+
children = [{ type: "paragraph", children }];
|
|
840
|
+
}
|
|
841
|
+
return [{ type: "listItem", spread: false, children }];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
case "code": {
|
|
845
|
+
const codeChild = Array.isArray(node.children)
|
|
846
|
+
? node.children.find((c: JxElement | string) => (c as JxMutableNode)?.tagName === "code")
|
|
847
|
+
: null;
|
|
848
|
+
const value = (codeChild as JxElement | undefined)?.textContent ?? text ?? "";
|
|
849
|
+
return [
|
|
850
|
+
{
|
|
851
|
+
type: "code",
|
|
852
|
+
lang: codeLang(codeChild as JxElement | undefined),
|
|
853
|
+
value,
|
|
854
|
+
},
|
|
855
|
+
];
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
case "thematicBreak":
|
|
859
|
+
return [{ type: "thematicBreak" }];
|
|
860
|
+
|
|
861
|
+
case "break":
|
|
862
|
+
return [{ type: "break" }];
|
|
863
|
+
|
|
864
|
+
case "table": {
|
|
865
|
+
const rows = exportChildren(node, ctx, scope).filter((c) => c.type === "tableRow");
|
|
866
|
+
if (rows.length === 0) return [];
|
|
867
|
+
return [{ type: "table", children: rows }];
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
case "thead":
|
|
871
|
+
case "tbody":
|
|
872
|
+
// Unwrap — promote rows
|
|
873
|
+
return exportChildren(node, ctx, scope);
|
|
874
|
+
|
|
875
|
+
case "tableRow": {
|
|
876
|
+
const cells = exportChildren(node, ctx, scope);
|
|
877
|
+
return [
|
|
878
|
+
{
|
|
879
|
+
type: "tableRow",
|
|
880
|
+
children: cells.filter((c) => c.type === "tableCell"),
|
|
881
|
+
},
|
|
882
|
+
];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
case "tableCell": {
|
|
886
|
+
const children =
|
|
887
|
+
text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
|
|
888
|
+
return [{ type: "tableCell", children }];
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return [];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function exportChildren(
|
|
896
|
+
node: JxElement,
|
|
897
|
+
ctx: ExportContext,
|
|
898
|
+
scope?: Record<string, unknown> | null,
|
|
899
|
+
): MdastNode[] {
|
|
900
|
+
if (node.textContent != null) {
|
|
901
|
+
const text = resolveText(node.textContent, ctx, scope);
|
|
902
|
+
if (text) return [{ type: "text", value: text }];
|
|
903
|
+
return [];
|
|
904
|
+
}
|
|
905
|
+
if (!Array.isArray(node.children)) return [];
|
|
906
|
+
return node.children.flatMap((c: JxElement | string) => nodeToMdast(c, ctx, scope));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ─── Export: component inlining ──────────────────────────────────────────────
|
|
910
|
+
|
|
911
|
+
function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): MdastNode[] {
|
|
912
|
+
const def = ctx.componentDefs.get(tag);
|
|
913
|
+
if (!def) {
|
|
914
|
+
return exportChildren(node, ctx);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Merge instance $props into component state
|
|
918
|
+
const props = node.$props ?? {};
|
|
919
|
+
const stateDefs = { ...def.state };
|
|
920
|
+
for (const [key, value] of Object.entries(props)) {
|
|
921
|
+
if (key in stateDefs) {
|
|
922
|
+
const existing = stateDefs[key];
|
|
923
|
+
if (
|
|
924
|
+
existing &&
|
|
925
|
+
typeof existing === "object" &&
|
|
926
|
+
!Array.isArray(existing) &&
|
|
927
|
+
"default" in existing
|
|
928
|
+
) {
|
|
929
|
+
stateDefs[key] = { ...existing, default: value };
|
|
930
|
+
} else {
|
|
931
|
+
stateDefs[key] = value as JxStateDefinition;
|
|
932
|
+
}
|
|
933
|
+
} else {
|
|
934
|
+
stateDefs[key] = value as JxStateDefinition;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const scope = ctx.buildScope?.(stateDefs) ?? null;
|
|
939
|
+
|
|
940
|
+
if (!Array.isArray(def.children)) return [];
|
|
941
|
+
|
|
942
|
+
const resolved = deepResolve(def.children, scope ?? {}, ctx);
|
|
943
|
+
|
|
944
|
+
const instanceChildren = node.children;
|
|
945
|
+
return resolved.flatMap((child: JxElement | string): MdastNode[] => {
|
|
946
|
+
// Replace slot elements with instance children
|
|
947
|
+
if (typeof child !== "string" && child?.tagName === "slot" && Array.isArray(instanceChildren)) {
|
|
948
|
+
return instanceChildren.flatMap((c: JxElement | string): MdastNode[] => nodeToMdast(c, ctx));
|
|
949
|
+
}
|
|
950
|
+
return nodeToMdast(child, ctx, scope);
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function deepResolve(
|
|
955
|
+
nodes: (JxElement | string)[],
|
|
956
|
+
scope: Record<string, unknown>,
|
|
957
|
+
ctx: ExportContext,
|
|
958
|
+
) {
|
|
959
|
+
if (!Array.isArray(nodes)) return [];
|
|
960
|
+
return nodes.map((node) => resolveNode(node, scope, ctx));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function resolveNode(
|
|
964
|
+
node: JxElement | string,
|
|
965
|
+
scope: Record<string, unknown>,
|
|
966
|
+
ctx: ExportContext,
|
|
967
|
+
): JxElement | string {
|
|
968
|
+
if (typeof node === "string") {
|
|
969
|
+
const evaluated = ctx.evaluateTemplate?.(node, scope);
|
|
970
|
+
return evaluated !== undefined ? String(evaluated) : node;
|
|
971
|
+
}
|
|
972
|
+
if (!node || typeof node !== "object") return node;
|
|
973
|
+
|
|
974
|
+
const result = { ...node };
|
|
975
|
+
|
|
976
|
+
if (typeof result.textContent === "string") {
|
|
977
|
+
const evaluated = ctx.evaluateTemplate?.(result.textContent, scope);
|
|
978
|
+
if (evaluated !== undefined) result.textContent = String(evaluated);
|
|
979
|
+
}
|
|
980
|
+
if (typeof result.innerHTML === "string") {
|
|
981
|
+
const evaluated = ctx.evaluateTemplate?.(result.innerHTML, scope);
|
|
982
|
+
if (evaluated !== undefined) result.innerHTML = String(evaluated);
|
|
983
|
+
}
|
|
984
|
+
if (result.attributes) {
|
|
985
|
+
result.attributes = { ...result.attributes };
|
|
986
|
+
for (const [k, v] of Object.entries(result.attributes)) {
|
|
987
|
+
if (typeof v === "string") {
|
|
988
|
+
const evaluated = ctx.evaluateTemplate?.(v, scope);
|
|
989
|
+
if (evaluated !== undefined) result.attributes[k] = evaluated;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (Array.isArray(result.children)) {
|
|
994
|
+
result.children = deepResolve(result.children, scope, ctx);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ─── Export: array expansion ─────────────────────────────────────────────────
|
|
1001
|
+
|
|
1002
|
+
function expandArray(
|
|
1003
|
+
arrayDef: JxElement,
|
|
1004
|
+
ctx: ExportContext,
|
|
1005
|
+
scope?: Record<string, unknown> | null,
|
|
1006
|
+
): MdastNode[] {
|
|
1007
|
+
const itemsRef = (arrayDef as JxMutableNode).items?.$ref;
|
|
1008
|
+
if (!itemsRef || !scope) return [];
|
|
1009
|
+
|
|
1010
|
+
const items = resolveRef(itemsRef, scope);
|
|
1011
|
+
if (!Array.isArray(items)) return [];
|
|
1012
|
+
|
|
1013
|
+
const mapTemplate = (arrayDef as JxMutableNode).map;
|
|
1014
|
+
if (!mapTemplate) return [];
|
|
1015
|
+
|
|
1016
|
+
return items.flatMap((item: JxMutableNode): MdastNode[] => {
|
|
1017
|
+
const resolved = resolveMapNode(mapTemplate, item);
|
|
1018
|
+
return nodeToMdast(resolved as JxElement | string, ctx, scope);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function resolveMapNode(node: JxMutableNode, item: Record<string, unknown>) {
|
|
1023
|
+
if (typeof node === "string") return node;
|
|
1024
|
+
if (!node || typeof node !== "object") return node;
|
|
1025
|
+
|
|
1026
|
+
const result = { ...node };
|
|
1027
|
+
|
|
1028
|
+
for (const [key, value] of Object.entries(result)) {
|
|
1029
|
+
if (value && typeof value === "object" && (value as JxMutableNode).$ref) {
|
|
1030
|
+
const ref = (value as JxMutableNode).$ref as string;
|
|
1031
|
+
if (ref.startsWith("$map/")) {
|
|
1032
|
+
const path = ref.slice("$map/".length);
|
|
1033
|
+
result[key] = resolvePath(
|
|
1034
|
+
item,
|
|
1035
|
+
path.startsWith("item/") ? path.slice("item/".length) : path,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (result.$props) {
|
|
1042
|
+
result.$props = resolveMapNode(result.$props, item);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (typeof result.textContent === "string" && result.textContent.startsWith("$map/")) {
|
|
1046
|
+
result.textContent = resolvePath(item, result.textContent.slice("$map/".length)) as unknown as
|
|
1047
|
+
| string
|
|
1048
|
+
| null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (Array.isArray(result.children)) {
|
|
1052
|
+
result.children = result.children.map((c) =>
|
|
1053
|
+
resolveMapNode(c as unknown as JxMutableNode, item),
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return result;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function resolvePath(obj: unknown, path: string) {
|
|
1061
|
+
const parts = path.split(/[/.]/);
|
|
1062
|
+
let current = obj as JxMutableNode;
|
|
1063
|
+
for (const part of parts) {
|
|
1064
|
+
if (current == null) return undefined;
|
|
1065
|
+
current = current[part];
|
|
1066
|
+
}
|
|
1067
|
+
return current;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function resolveRef(ref: string, scope: Record<string, unknown>) {
|
|
1071
|
+
if (ref.startsWith("#/state/")) {
|
|
1072
|
+
return resolvePath(scope, ref.slice("#/state/".length));
|
|
1073
|
+
}
|
|
1074
|
+
return resolvePath(scope, ref);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function resolveText(
|
|
1078
|
+
value: unknown,
|
|
1079
|
+
ctx: ExportContext,
|
|
1080
|
+
scope?: Record<string, unknown> | null,
|
|
1081
|
+
): string | null {
|
|
1082
|
+
if (value == null) return null;
|
|
1083
|
+
if (typeof value === "string") {
|
|
1084
|
+
if (scope) {
|
|
1085
|
+
const evaluated = ctx.evaluateTemplate?.(value, scope);
|
|
1086
|
+
if (evaluated !== undefined) return String(evaluated);
|
|
1087
|
+
}
|
|
1088
|
+
return value;
|
|
1089
|
+
}
|
|
1090
|
+
return String(value);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ─── Export: HTML → mdast (innerHTML handling) ──────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
function htmlToMdast(html: string) {
|
|
1096
|
+
const nodes: MdastNode[] = [];
|
|
1097
|
+
|
|
1098
|
+
const parts = splitHtmlBlocks(html);
|
|
1099
|
+
for (const part of parts) {
|
|
1100
|
+
const trimmed = part.trim();
|
|
1101
|
+
if (!trimmed) continue;
|
|
1102
|
+
const parsed = parseHtmlElement(trimmed);
|
|
1103
|
+
if (parsed) nodes.push(...parsed);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
return nodes;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function splitHtmlBlocks(html: string) {
|
|
1110
|
+
const blocks: string[] = [];
|
|
1111
|
+
const trimmed = html.trim();
|
|
1112
|
+
|
|
1113
|
+
const pattern =
|
|
1114
|
+
/(<(?:h[1-6]|p|blockquote|pre|ul|ol|hr|table|div|section|article|aside|figure|nav|header|footer|main)[\s>][\s\S]*?<\/(?:h[1-6]|p|blockquote|pre|ul|ol|table|div|section|article|aside|figure|nav|header|footer|main)>|<hr\s*\/?>)/gi;
|
|
1115
|
+
let lastIdx = 0;
|
|
1116
|
+
let m;
|
|
1117
|
+
while ((m = pattern.exec(trimmed)) !== null) {
|
|
1118
|
+
if (m.index > lastIdx) {
|
|
1119
|
+
const between = trimmed.slice(lastIdx, m.index).trim();
|
|
1120
|
+
if (between) blocks.push(between);
|
|
1121
|
+
}
|
|
1122
|
+
blocks.push(m[0]);
|
|
1123
|
+
lastIdx = pattern.lastIndex;
|
|
1124
|
+
}
|
|
1125
|
+
if (lastIdx < trimmed.length) {
|
|
1126
|
+
const tail = trimmed.slice(lastIdx).trim();
|
|
1127
|
+
if (tail) blocks.push(tail);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return blocks;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function parseHtmlElement(html: string) {
|
|
1134
|
+
const hMatch = html.match(/^<(h[1-6])(?:\s[^>]*)?>(.+?)<\/\1>$/is);
|
|
1135
|
+
if (hMatch) {
|
|
1136
|
+
const depth = parseInt(hMatch[1].slice(1), 10);
|
|
1137
|
+
const children = parseInlineHtml(hMatch[2]);
|
|
1138
|
+
return [{ type: "heading", depth, children }];
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const pMatch = html.match(/^<p(?:\s[^>]*)?>(.+?)<\/p>$/is);
|
|
1142
|
+
if (pMatch) {
|
|
1143
|
+
const children = parseInlineHtml(pMatch[1]);
|
|
1144
|
+
if (children.length === 0) return null;
|
|
1145
|
+
return [{ type: "paragraph", children }];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (/^<hr\s*\/?>$/i.test(html)) {
|
|
1149
|
+
return [{ type: "thematicBreak" }];
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const preMatch = html.match(
|
|
1153
|
+
/^<pre(?:\s[^>]*)?>\s*<code(?:\s+class="language-(\w+)")?(?:\s[^>]*)?>([^]*?)<\/code>\s*<\/pre>$/is,
|
|
1154
|
+
);
|
|
1155
|
+
if (preMatch) {
|
|
1156
|
+
const lang = preMatch[1] ?? null;
|
|
1157
|
+
const value = decodeHtmlEntities(preMatch[2]);
|
|
1158
|
+
return [{ type: "code", lang, value }];
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const bqMatch = html.match(/^<blockquote(?:\s[^>]*)?>([^]*?)<\/blockquote>$/is);
|
|
1162
|
+
if (bqMatch) {
|
|
1163
|
+
const inner = htmlToMdast(bqMatch[1]);
|
|
1164
|
+
const children = inner.map((c) =>
|
|
1165
|
+
c.type === "text" ? { type: "paragraph", children: [c] } : c,
|
|
1166
|
+
);
|
|
1167
|
+
return [{ type: "blockquote", children }];
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const ulMatch = html.match(/^<ul(?:\s[^>]*)?>([^]*?)<\/ul>$/is);
|
|
1171
|
+
if (ulMatch) {
|
|
1172
|
+
const items = parseListItems(ulMatch[1]);
|
|
1173
|
+
if (items.length === 0) return null;
|
|
1174
|
+
return [{ type: "list", ordered: false, spread: false, children: items }];
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const olMatch = html.match(/^<ol(?:\s[^>]*)?>([^]*?)<\/ol>$/is);
|
|
1178
|
+
if (olMatch) {
|
|
1179
|
+
const items = parseListItems(olMatch[1]);
|
|
1180
|
+
if (items.length === 0) return null;
|
|
1181
|
+
return [{ type: "list", ordered: true, spread: false, children: items }];
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const tableMatch = html.match(/^<table(?:\s[^>]*)?>([^]*?)<\/table>$/is);
|
|
1185
|
+
if (tableMatch) {
|
|
1186
|
+
return parseHtmlTable(tableMatch[1]);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const wrapperMatch = html.match(
|
|
1190
|
+
/^<(?:div|section|article|aside|figure|nav|header|footer|main)(?:\s[^>]*)?>([^]*?)<\/(?:div|section|article|aside|figure|nav|header|footer|main)>$/is,
|
|
1191
|
+
);
|
|
1192
|
+
if (wrapperMatch) {
|
|
1193
|
+
return htmlToMdast(wrapperMatch[1]);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const text = stripHtmlTags(html).trim();
|
|
1197
|
+
if (text) return [{ type: "paragraph", children: parseInlineHtml(html) }];
|
|
1198
|
+
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function parseInlineHtml(html: string) {
|
|
1203
|
+
const nodes: MdastNode[] = [];
|
|
1204
|
+
let pos = 0;
|
|
1205
|
+
|
|
1206
|
+
while (pos < html.length) {
|
|
1207
|
+
const tagStart = html.indexOf("<", pos);
|
|
1208
|
+
if (tagStart === -1) {
|
|
1209
|
+
const text = decodeHtmlEntities(html.slice(pos));
|
|
1210
|
+
if (text.trim()) nodes.push({ type: "text", value: text });
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (tagStart > pos) {
|
|
1215
|
+
const text = decodeHtmlEntities(html.slice(pos, tagStart));
|
|
1216
|
+
if (text.trim()) nodes.push({ type: "text", value: text });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const brMatch = html.slice(tagStart).match(/^<br\s*\/?>/i);
|
|
1220
|
+
if (brMatch) {
|
|
1221
|
+
nodes.push({ type: "break" });
|
|
1222
|
+
pos = tagStart + brMatch[0].length;
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const imgMatch = html.slice(tagStart).match(/^<img(\s[^>]*?)\/?>/i);
|
|
1227
|
+
if (imgMatch) {
|
|
1228
|
+
const attrs = imgMatch[1] ?? "";
|
|
1229
|
+
const src = attrs.match(/src="([^"]*)"/)?.[1] ?? "";
|
|
1230
|
+
const alt = attrs.match(/alt="([^"]*)"/)?.[1] ?? "";
|
|
1231
|
+
nodes.push({
|
|
1232
|
+
type: "image",
|
|
1233
|
+
url: decodeHtmlEntities(src),
|
|
1234
|
+
alt: decodeHtmlEntities(alt),
|
|
1235
|
+
});
|
|
1236
|
+
pos = tagStart + imgMatch[0].length;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const openMatch = html.slice(tagStart).match(/^<(a|em|strong|del|code|b|i|s)(\s[^>]*)?>/);
|
|
1241
|
+
if (openMatch) {
|
|
1242
|
+
const tag = openMatch[1].toLowerCase();
|
|
1243
|
+
const attrs = openMatch[2] ?? "";
|
|
1244
|
+
const innerStart = tagStart + openMatch[0].length;
|
|
1245
|
+
const closeTag = `</${tag}>`;
|
|
1246
|
+
const closeIdx = findMatchingClose(html, innerStart, tag);
|
|
1247
|
+
if (closeIdx === -1) {
|
|
1248
|
+
pos = tagStart + 1;
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const inner = html.slice(innerStart, closeIdx);
|
|
1252
|
+
pos = closeIdx + closeTag.length;
|
|
1253
|
+
|
|
1254
|
+
switch (tag) {
|
|
1255
|
+
case "a": {
|
|
1256
|
+
const href = attrs.match(/href="([^"]*)"/)?.[1] ?? "";
|
|
1257
|
+
const title = attrs.match(/title="([^"]*)"/)?.[1] ?? null;
|
|
1258
|
+
const children = parseInlineHtml(inner);
|
|
1259
|
+
if (children.length === 0)
|
|
1260
|
+
children.push({ type: "text", value: decodeHtmlEntities(inner) });
|
|
1261
|
+
nodes.push({
|
|
1262
|
+
type: "link",
|
|
1263
|
+
url: decodeHtmlEntities(href),
|
|
1264
|
+
title,
|
|
1265
|
+
children,
|
|
1266
|
+
});
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
case "em":
|
|
1270
|
+
case "i":
|
|
1271
|
+
nodes.push({ type: "emphasis", children: parseInlineHtml(inner) });
|
|
1272
|
+
break;
|
|
1273
|
+
case "strong":
|
|
1274
|
+
case "b":
|
|
1275
|
+
nodes.push({ type: "strong", children: parseInlineHtml(inner) });
|
|
1276
|
+
break;
|
|
1277
|
+
case "del":
|
|
1278
|
+
case "s":
|
|
1279
|
+
nodes.push({ type: "delete", children: parseInlineHtml(inner) });
|
|
1280
|
+
break;
|
|
1281
|
+
case "code":
|
|
1282
|
+
nodes.push({ type: "inlineCode", value: decodeHtmlEntities(inner) });
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const skipMatch = html.slice(tagStart).match(/^<[^>]*>/);
|
|
1289
|
+
if (skipMatch) {
|
|
1290
|
+
pos = tagStart + skipMatch[0].length;
|
|
1291
|
+
} else {
|
|
1292
|
+
pos = tagStart + 1;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return nodes;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function findMatchingClose(html: string, start: number, tag: string) {
|
|
1300
|
+
let depth = 1;
|
|
1301
|
+
const openRe = new RegExp(`<${tag}[\\s>]`, "gi");
|
|
1302
|
+
const closeRe = new RegExp(`</${tag}>`, "gi");
|
|
1303
|
+
openRe.lastIndex = start;
|
|
1304
|
+
closeRe.lastIndex = start;
|
|
1305
|
+
|
|
1306
|
+
while (depth > 0) {
|
|
1307
|
+
const openMatch = openRe.exec(html);
|
|
1308
|
+
const closeMatch = closeRe.exec(html);
|
|
1309
|
+
|
|
1310
|
+
if (!closeMatch) return -1;
|
|
1311
|
+
|
|
1312
|
+
if (openMatch && openMatch.index < closeMatch.index) {
|
|
1313
|
+
depth++;
|
|
1314
|
+
openRe.lastIndex = openMatch.index + openMatch[0].length;
|
|
1315
|
+
closeRe.lastIndex = closeMatch.index; // re-check this close
|
|
1316
|
+
} else {
|
|
1317
|
+
depth--;
|
|
1318
|
+
if (depth === 0) return closeMatch.index;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return -1;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function parseListItems(html: string) {
|
|
1325
|
+
const items: MdastNode[] = [];
|
|
1326
|
+
const liPattern = /<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi;
|
|
1327
|
+
let m;
|
|
1328
|
+
while ((m = liPattern.exec(html)) !== null) {
|
|
1329
|
+
const inner = m[1].trim();
|
|
1330
|
+
const innerNodes = /<(?:p|ul|ol|blockquote|pre)[\s>]/i.test(inner)
|
|
1331
|
+
? htmlToMdast(inner)
|
|
1332
|
+
: [{ type: "paragraph", children: parseInlineHtml(inner) }];
|
|
1333
|
+
items.push({ type: "listItem", spread: false, children: innerNodes });
|
|
1334
|
+
}
|
|
1335
|
+
return items;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function parseHtmlTable(html: string) {
|
|
1339
|
+
const rows: MdastNode[] = [];
|
|
1340
|
+
const trPattern = /<tr(?:\s[^>]*)?>([\s\S]*?)<\/tr>/gi;
|
|
1341
|
+
let m;
|
|
1342
|
+
while ((m = trPattern.exec(html)) !== null) {
|
|
1343
|
+
const cellPattern = /<(?:th|td)(?:\s[^>]*)?>([\s\S]*?)<\/(?:th|td)>/gi;
|
|
1344
|
+
const cells: MdastNode[] = [];
|
|
1345
|
+
let c;
|
|
1346
|
+
while ((c = cellPattern.exec(m[1])) !== null) {
|
|
1347
|
+
cells.push({ type: "tableCell", children: parseInlineHtml(c[1]) });
|
|
1348
|
+
}
|
|
1349
|
+
if (cells.length > 0) rows.push({ type: "tableRow", children: cells });
|
|
1350
|
+
}
|
|
1351
|
+
if (rows.length === 0) return [];
|
|
1352
|
+
return [{ type: "table", children: rows }];
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function stripHtmlTags(html: string) {
|
|
1356
|
+
return html.replace(/<[^>]+>/g, "");
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function decodeHtmlEntities(str: string) {
|
|
1360
|
+
return str
|
|
1361
|
+
.replace(/&/g, "&")
|
|
1362
|
+
.replace(/</g, "<")
|
|
1363
|
+
.replace(/>/g, ">")
|
|
1364
|
+
.replace(/"/g, '"')
|
|
1365
|
+
.replace(/'/g, "'")
|
|
1366
|
+
.replace(/$/g, "$")
|
|
1367
|
+
.replace(/ /g, " ");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1371
|
+
// Public entry point
|
|
1372
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Serialize a Jx document to markdown source.
|
|
1376
|
+
*
|
|
1377
|
+
* - `mode: "roundtrip"` (default) — frontmatter + directives; inverse of `transpileJxMarkdown()`.
|
|
1378
|
+
* - `mode: "export"` — clean GFM: Jx decoration stripped, wrappers unwrapped, custom elements inlined
|
|
1379
|
+
* via `componentDefs`, templates evaluated via injected hooks.
|
|
1380
|
+
*/
|
|
1381
|
+
export function serializeJxMarkdown(doc: JxDocument, opts: SerializeOptions = {}): string {
|
|
1382
|
+
const mode = opts.mode ?? "roundtrip";
|
|
1383
|
+
|
|
1384
|
+
if (mode === "export") {
|
|
1385
|
+
return serializeExport(doc, opts);
|
|
1386
|
+
}
|
|
1387
|
+
return serializeRoundtrip(doc, opts);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
|
|
1391
|
+
const lines: string[] = [];
|
|
1392
|
+
|
|
1393
|
+
if (opts.frontmatter !== false) {
|
|
1394
|
+
const frontmatter: Record<string, unknown> = {};
|
|
1395
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
1396
|
+
if (key === "children") continue;
|
|
1397
|
+
frontmatter[key] = value;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (Object.keys(frontmatter).length > 0) {
|
|
1401
|
+
lines.push("---");
|
|
1402
|
+
lines.push(stringifyYaml(frontmatter).trim());
|
|
1403
|
+
lines.push("---");
|
|
1404
|
+
lines.push("");
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (Array.isArray(doc.children) && doc.children.length > 0) {
|
|
1409
|
+
const mdast = jxToMdast(doc as JxElement, opts);
|
|
1410
|
+
const md = unified()
|
|
1411
|
+
.use(remarkGfm)
|
|
1412
|
+
.use(remarkDirective)
|
|
1413
|
+
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
|
1414
|
+
.stringify(mdast as unknown as import("mdast").Root);
|
|
1415
|
+
|
|
1416
|
+
lines.push(md as string);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return (
|
|
1420
|
+
lines
|
|
1421
|
+
.join("\n")
|
|
1422
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1423
|
+
.trim() + "\n"
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function serializeExport(doc: JxDocument, opts: SerializeOptions): string {
|
|
1428
|
+
if (!Array.isArray(doc.children) || doc.children.length === 0) {
|
|
1429
|
+
return "";
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const ctx: ExportContext = {
|
|
1433
|
+
componentDefs: opts.componentDefs ?? new Map(),
|
|
1434
|
+
evaluateTemplate: opts.evaluateTemplate,
|
|
1435
|
+
buildScope: opts.buildScope,
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// Build scope from resolved state for any remaining template expressions
|
|
1439
|
+
const scope = doc.state ? (opts.buildScope?.(doc.state) ?? null) : null;
|
|
1440
|
+
|
|
1441
|
+
const mdastChildren = doc.children.flatMap((child: JxElement | string) =>
|
|
1442
|
+
nodeToMdast(child, ctx, scope),
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
// Ensure block-level structure at root (no bare inline nodes)
|
|
1446
|
+
const cleaned: MdastNode[] = [];
|
|
1447
|
+
let inlineBuf: MdastNode[] = [];
|
|
1448
|
+
|
|
1449
|
+
const flushInline = () => {
|
|
1450
|
+
if (inlineBuf.length > 0) {
|
|
1451
|
+
cleaned.push({ type: "paragraph", children: inlineBuf });
|
|
1452
|
+
inlineBuf = [];
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
for (const node of mdastChildren) {
|
|
1457
|
+
if (isInlineType(node.type)) {
|
|
1458
|
+
inlineBuf.push(node);
|
|
1459
|
+
} else {
|
|
1460
|
+
flushInline();
|
|
1461
|
+
cleaned.push(node);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
flushInline();
|
|
1465
|
+
|
|
1466
|
+
const mdast = {
|
|
1467
|
+
type: "root",
|
|
1468
|
+
children: cleaned,
|
|
1469
|
+
} as unknown as import("mdast").Root;
|
|
1470
|
+
|
|
1471
|
+
const md = unified()
|
|
1472
|
+
.use(remarkGfm)
|
|
1473
|
+
.use(remarkStringify, {
|
|
1474
|
+
bullet: "-",
|
|
1475
|
+
emphasis: "*",
|
|
1476
|
+
strong: "*",
|
|
1477
|
+
setext: false,
|
|
1478
|
+
})
|
|
1479
|
+
.stringify(mdast);
|
|
1480
|
+
|
|
1481
|
+
return md.replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
1482
|
+
}
|