@jxsuite/parser 0.27.0 → 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.
@@ -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(/&amp;/g, "&")
1362
+ .replace(/&lt;/g, "<")
1363
+ .replace(/&gt;/g, ">")
1364
+ .replace(/&quot;/g, '"')
1365
+ .replace(/&#39;/g, "'")
1366
+ .replace(/&#36;/g, "$")
1367
+ .replace(/&nbsp;/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
+ }