@jxsuite/compiler 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.
@@ -1,977 +0,0 @@
1
- /**
2
- * Compile-markdown.js — Clean markdown export target
3
- *
4
- * Converts a fully-resolved Jx document tree to pure markdown, stripping all Jx-specific decoration
5
- * (styles, attributes, custom element wrappers). Components are inlined by resolving their
6
- * definitions with instance props.
7
- */
8
-
9
- import { unified } from "unified";
10
- import remarkStringify from "remark-stringify";
11
- import remarkGfm from "remark-gfm";
12
- import { buildInitialScope, evaluateStaticTemplate, isTemplateString } from "../shared.ts";
13
- import type {
14
- JxElement,
15
- JxDocument,
16
- JxStateDefinition,
17
- JxMutableNode,
18
- } from "@jxsuite/schema/types";
19
- import type { MdastNode } from "@jxsuite/parser/types";
20
-
21
- // ─── Tag classification ────────────────────────────────────────────────────
22
-
23
- /** Tags that map directly to mdast node types. */
24
- const TAG_MDAST_MAP = {
25
- h1: "heading",
26
- h2: "heading",
27
- h3: "heading",
28
- h4: "heading",
29
- h5: "heading",
30
- h6: "heading",
31
- p: "paragraph",
32
- em: "emphasis",
33
- strong: "strong",
34
- del: "delete",
35
- code: "inlineCode",
36
- a: "link",
37
- img: "image",
38
- blockquote: "blockquote",
39
- ul: "list",
40
- ol: "list",
41
- li: "listItem",
42
- pre: "code",
43
- hr: "thematicBreak",
44
- br: "break",
45
- table: "table",
46
- thead: "thead",
47
- tbody: "tbody",
48
- tr: "tableRow",
49
- th: "tableCell",
50
- td: "tableCell",
51
- } as Record<string, string>;
52
-
53
- /** Wrapper tags that should be unwrapped — their children are promoted. */
54
- const WRAPPER_TAGS = new Set([
55
- "div",
56
- "section",
57
- "span",
58
- "nav",
59
- "header",
60
- "footer",
61
- "main",
62
- "article",
63
- "aside",
64
- "figure",
65
- "figcaption",
66
- "slot",
67
- ]);
68
-
69
- // ─── Core conversion ────────────────────────────────────────────────────────
70
-
71
- /**
72
- * Convert a Jx node to an array of mdast nodes. Returns an array because wrapper unwrapping can
73
- * produce multiple children.
74
- *
75
- * @param {JxElement | string} node
76
- * @param {Map<string, JxElement>} componentDefs
77
- * @param {Record<string, unknown> | null} [scope] - Current resolution scope
78
- * @returns {MdastNode[]}
79
- */
80
- function nodeToMdast(
81
- node: JxElement | string,
82
- componentDefs: Map<string, JxElement>,
83
- scope?: Record<string, unknown> | null,
84
- ): MdastNode[] {
85
- // Bare text
86
- if (typeof node === "string") {
87
- return node.trim() ? [{ type: "text", value: node }] : [];
88
- }
89
- if (typeof node === "number") {
90
- return [{ type: "text", value: String(node) }];
91
- }
92
- if (!node || typeof node !== "object") return [];
93
-
94
- // Array descriptor — expand mapped arrays
95
- if (node.$prototype === "Array") {
96
- return expandArray(node, componentDefs, scope);
97
- }
98
-
99
- const tag = node.tagName ?? "div";
100
-
101
- // Resolve text content
102
- const text = resolveText(node.textContent, scope);
103
-
104
- // innerHTML — if present, convert HTML content to mdast
105
- if (typeof node.innerHTML === "string" && node.innerHTML.trim()) {
106
- const htmlNodes = htmlToMdast(node.innerHTML);
107
- if (htmlNodes.length > 0) return htmlNodes;
108
- }
109
-
110
- // Custom elements — inline component content
111
- if (tag.includes("-")) {
112
- return inlineComponent(node, tag, componentDefs);
113
- }
114
-
115
- // Wrapper tags — unwrap, promote children
116
- if (WRAPPER_TAGS.has(tag)) {
117
- // If wrapper has only textContent, wrap in paragraph for block structure
118
- if (text != null) {
119
- return text.trim() ? [{ type: "paragraph", children: [{ type: "text", value: text }] }] : [];
120
- }
121
- return convertChildren(node, componentDefs, scope);
122
- }
123
-
124
- const mdastType = TAG_MDAST_MAP[tag];
125
- if (!mdastType) {
126
- // Unknown tag — wrap textContent in paragraph, or unwrap children
127
- if (text != null) {
128
- return text.trim() ? [{ type: "paragraph", children: [{ type: "text", value: text }] }] : [];
129
- }
130
- return convertChildren(node, componentDefs, scope);
131
- }
132
-
133
- // Standard markdown elements
134
- switch (mdastType) {
135
- case "heading": {
136
- const depth = parseInt(tag.slice(1), 10);
137
- const children =
138
- text != null
139
- ? [{ type: "text", value: text }]
140
- : convertChildrenInline(node, componentDefs, scope);
141
- return [{ type: "heading", depth, children }];
142
- }
143
-
144
- case "paragraph": {
145
- const children =
146
- text != null
147
- ? [{ type: "text", value: text }]
148
- : convertChildrenInline(node, componentDefs, scope);
149
- if (children.length === 0) return [];
150
- return [{ type: "paragraph", children }];
151
- }
152
-
153
- case "emphasis":
154
- case "strong":
155
- case "delete": {
156
- const children =
157
- text != null
158
- ? [{ type: "text", value: text }]
159
- : convertChildrenInline(node, componentDefs, scope);
160
- return [{ type: mdastType, children }];
161
- }
162
-
163
- case "inlineCode":
164
- return [{ type: "inlineCode", value: text ?? "" }];
165
-
166
- case "link": {
167
- const href = String(node.attributes?.href ?? "");
168
- const title = (node.attributes?.title as string | null) ?? null;
169
- const children =
170
- text != null
171
- ? [{ type: "text", value: text }]
172
- : convertChildrenInline(node, componentDefs, scope);
173
- return [{ type: "link", url: href, title, children }];
174
- }
175
-
176
- case "image": {
177
- const src = String(node.attributes?.src ?? "");
178
- const alt = String(node.attributes?.alt ?? "");
179
- const title = (node.attributes?.title as string | null) ?? null;
180
- return [{ type: "image", url: src, alt, title }];
181
- }
182
-
183
- case "blockquote": {
184
- const children = convertChildren(node, componentDefs, scope);
185
- // Wrap bare text in paragraph if needed
186
- const wrapped = children.map((c) =>
187
- c.type === "text" ? { type: "paragraph", children: [c] } : c,
188
- );
189
- return [{ type: "blockquote", children: wrapped }];
190
- }
191
-
192
- case "list": {
193
- const ordered = tag === "ol";
194
- const children = convertChildren(node, componentDefs, scope);
195
- // Only keep listItem children
196
- const items = children.filter((c) => c.type === "listItem");
197
- if (items.length === 0) return [];
198
- return [{ type: "list", ordered, spread: false, children: items }];
199
- }
200
-
201
- case "listItem": {
202
- let children = convertChildren(node, componentDefs, scope);
203
- // Wrap bare text/inline nodes in paragraph
204
- if (children.length > 0 && children.every((c) => c.type === "text" || isInlineType(c.type))) {
205
- children = [{ type: "paragraph", children }];
206
- }
207
- return [{ type: "listItem", spread: false, children }];
208
- }
209
-
210
- case "code": {
211
- // pre > code → fenced code block
212
- const codeChild = Array.isArray(node.children)
213
- ? node.children.find((c: JxElement | string) => (c as JxMutableNode)?.tagName === "code")
214
- : null;
215
- const value = (codeChild as JxElement | undefined)?.textContent ?? text ?? "";
216
- const lang =
217
- (codeChild as JxElement | undefined)?.className?.replace("language-", "") ?? null;
218
- return [{ type: "code", lang, value }];
219
- }
220
-
221
- case "thematicBreak":
222
- return [{ type: "thematicBreak" }];
223
-
224
- case "break":
225
- return [{ type: "break" }];
226
-
227
- case "table":
228
- return convertTable(node, componentDefs, scope);
229
-
230
- case "thead":
231
- case "tbody":
232
- // Unwrap — promote rows
233
- return convertChildren(node, componentDefs, scope);
234
-
235
- case "tableRow": {
236
- const cells = convertChildren(node, componentDefs, scope);
237
- return [{ type: "tableRow", children: cells.filter((c) => c.type === "tableCell") }];
238
- }
239
-
240
- case "tableCell": {
241
- const children =
242
- text != null
243
- ? [{ type: "text", value: text }]
244
- : convertChildrenInline(node, componentDefs, scope);
245
- return [{ type: "tableCell", children }];
246
- }
247
- }
248
-
249
- return [];
250
- }
251
-
252
- /**
253
- * @param {string} type
254
- * @returns {boolean}
255
- */
256
- function isInlineType(type: string) {
257
- return ["text", "emphasis", "strong", "delete", "inlineCode", "link", "image", "break"].includes(
258
- type,
259
- );
260
- }
261
-
262
- /**
263
- * Convert a node's children to mdast nodes (block context).
264
- *
265
- * @param {JxElement} node
266
- * @param {Map<string, JxElement>} componentDefs
267
- * @param {Record<string, unknown> | null} [scope]
268
- * @returns {MdastNode[]}
269
- */
270
- function convertChildren(
271
- node: JxElement,
272
- componentDefs: Map<string, JxElement>,
273
- scope?: Record<string, unknown> | null,
274
- ): MdastNode[] {
275
- if (node.textContent != null) {
276
- const text = resolveText(node.textContent, scope);
277
- if (text) return [{ type: "text", value: text }];
278
- return [];
279
- }
280
- if (!Array.isArray(node.children)) return [];
281
- return node.children.flatMap((c: JxElement | string) => nodeToMdast(c, componentDefs, scope));
282
- }
283
-
284
- /**
285
- * Convert children in inline context — same as convertChildren but for inline content.
286
- *
287
- * @param {JxElement} node
288
- * @param {Map<string, JxElement>} componentDefs
289
- * @param {Record<string, unknown> | null} [scope]
290
- * @returns {MdastNode[]}
291
- */
292
- function convertChildrenInline(
293
- node: JxElement,
294
- componentDefs: Map<string, JxElement>,
295
- scope?: Record<string, unknown> | null,
296
- ): MdastNode[] {
297
- if (node.textContent != null) {
298
- const text = resolveText(node.textContent, scope);
299
- if (text) return [{ type: "text", value: text }];
300
- return [];
301
- }
302
- if (!Array.isArray(node.children)) return [];
303
- return node.children.flatMap((c: JxElement | string) => nodeToMdast(c, componentDefs, scope));
304
- }
305
-
306
- // ─── Component inlining ─────────────────────────────────────────────────────
307
-
308
- /**
309
- * Inline a custom element by resolving its component definition.
310
- *
311
- * @param {JxElement} node - The element instance (with $props, children, etc.)
312
- * @param {string} tag - The tagName
313
- * @param {Map<string, JxElement>} componentDefs
314
- * @returns {MdastNode[]}
315
- */
316
- function inlineComponent(
317
- node: JxElement,
318
- tag: string,
319
- componentDefs: Map<string, JxElement>,
320
- ): MdastNode[] {
321
- const def = componentDefs.get(tag);
322
- if (!def) {
323
- // No definition — unwrap any children the instance has
324
- return convertChildren(node, componentDefs);
325
- }
326
-
327
- // Merge instance $props into component state
328
- const props = node.$props ?? {};
329
- let stateDefs = { ...def.state };
330
- for (const [key, value] of Object.entries(props)) {
331
- if (key in stateDefs) {
332
- const existing = stateDefs[key];
333
- if (
334
- existing &&
335
- typeof existing === "object" &&
336
- !Array.isArray(existing) &&
337
- "default" in existing
338
- ) {
339
- stateDefs[key] = { ...existing, default: value };
340
- } else {
341
- stateDefs[key] = value as JxStateDefinition;
342
- }
343
- } else {
344
- stateDefs[key] = value as JxStateDefinition;
345
- }
346
- }
347
-
348
- const scope = buildInitialScope(stateDefs, null);
349
-
350
- // Resolve the component's children with the merged scope
351
- if (!Array.isArray(def.children)) return [];
352
-
353
- // Deep-resolve template expressions in the component's children
354
- const resolved = deepResolve(def.children, scope);
355
-
356
- // Convert to mdast, passing instance's own children as potential slot content
357
- const instanceChildren = node.children;
358
- return resolved.flatMap((child: JxElement | string): MdastNode[] => {
359
- // Replace slot elements with instance children
360
- if (typeof child !== "string" && child?.tagName === "slot" && Array.isArray(instanceChildren)) {
361
- return instanceChildren.flatMap((c: JxElement | string): MdastNode[] =>
362
- nodeToMdast(c, componentDefs),
363
- );
364
- }
365
- return nodeToMdast(child, componentDefs, scope);
366
- });
367
- }
368
-
369
- /**
370
- * Deep-resolve template expressions in a node tree.
371
- *
372
- * @param {(JxElement | string)[]} nodes
373
- * @param {Record<string, unknown>} scope
374
- * @returns {(JxElement | string)[]}
375
- */
376
- function deepResolve(nodes: (JxElement | string)[], scope: Record<string, unknown>) {
377
- if (!Array.isArray(nodes)) return [];
378
- return nodes.map((node: JxElement | string) => resolveNode(node, scope));
379
- }
380
-
381
- /**
382
- * Resolve template expressions in a single node.
383
- *
384
- * @param {JxElement | string} node
385
- * @param {Record<string, unknown>} scope
386
- * @returns {JxElement | string}
387
- */
388
- function resolveNode(node: JxElement | string, scope: Record<string, unknown>) {
389
- if (typeof node === "string") {
390
- return isTemplateString(node) ? String(evaluateStaticTemplate(node, scope) ?? node) : node;
391
- }
392
- if (!node || typeof node !== "object") return node;
393
-
394
- const result = { ...node };
395
-
396
- if (typeof result.textContent === "string" && isTemplateString(result.textContent)) {
397
- result.textContent = String(
398
- evaluateStaticTemplate(result.textContent, scope) ?? result.textContent,
399
- );
400
- }
401
- if (typeof result.innerHTML === "string" && isTemplateString(result.innerHTML)) {
402
- result.innerHTML = String(evaluateStaticTemplate(result.innerHTML, scope) ?? result.innerHTML);
403
- }
404
- if (result.attributes) {
405
- result.attributes = { ...result.attributes };
406
- for (const [k, v] of Object.entries(result.attributes)) {
407
- if (typeof v === "string" && isTemplateString(v)) {
408
- result.attributes[k] = evaluateStaticTemplate(v, scope) ?? v;
409
- }
410
- }
411
- }
412
- if (Array.isArray(result.children)) {
413
- result.children = deepResolve(result.children, scope);
414
- }
415
-
416
- return result;
417
- }
418
-
419
- // ─── Array expansion ────────────────────────────────────────────────────────
420
-
421
- /**
422
- * Expand a $prototype: "Array" descriptor into concrete mdast nodes.
423
- *
424
- * @param {JxElement} arrayDef
425
- * @param {Map<string, JxElement>} componentDefs
426
- * @param {Record<string, unknown> | null} [scope]
427
- * @returns {MdastNode[]}
428
- */
429
- function expandArray(
430
- arrayDef: JxElement,
431
- componentDefs: Map<string, JxElement>,
432
- scope?: Record<string, unknown> | null,
433
- ): MdastNode[] {
434
- const itemsRef = (arrayDef as JxMutableNode).items?.$ref;
435
- if (!itemsRef || !scope) return [];
436
-
437
- // Resolve the items array from scope
438
- const items = resolveRef(itemsRef, scope);
439
- if (!Array.isArray(items)) return [];
440
-
441
- const mapTemplate = (arrayDef as JxMutableNode).map;
442
- if (!mapTemplate) return [];
443
-
444
- return items.flatMap((item: JxMutableNode, index: number): MdastNode[] => {
445
- // Create a scope with $map values
446
- const mapScope = Object.create(scope);
447
- mapScope.item = item;
448
- mapScope.index = index;
449
-
450
- // Resolve the map template with $map refs
451
- const resolved = resolveMapNode(mapTemplate, item);
452
- return nodeToMdast(resolved as JxElement | string, componentDefs, scope);
453
- });
454
- }
455
-
456
- /**
457
- * Resolve $map/ references in a map template node.
458
- *
459
- * @param {JxMutableNode} node
460
- * @param {Record<string, unknown>} item
461
- * @returns {any}
462
- */
463
- function resolveMapNode(node: JxMutableNode, item: Record<string, unknown>) {
464
- if (typeof node === "string") return node;
465
- if (!node || typeof node !== "object") return node;
466
-
467
- const result = { ...node };
468
-
469
- // Resolve $ref values
470
- for (const [key, value] of Object.entries(result)) {
471
- if (value && typeof value === "object" && (value as JxMutableNode).$ref) {
472
- const ref = (value as JxMutableNode).$ref as string;
473
- if (ref.startsWith("$map/")) {
474
- const path = ref.slice("$map/".length);
475
- result[key] = resolvePath(
476
- path === "item" ? item : item,
477
- path.startsWith("item/") ? path.slice("item/".length) : path,
478
- );
479
- }
480
- }
481
- }
482
-
483
- if (result.$props) {
484
- result.$props = resolveMapNode(result.$props, item);
485
- }
486
-
487
- if (typeof result.textContent === "string" && result.textContent.startsWith("$map/")) {
488
- result.textContent = resolvePath(item, result.textContent.slice("$map/".length)) as unknown as
489
- | string
490
- | null;
491
- }
492
-
493
- if (Array.isArray(result.children)) {
494
- result.children = result.children.map((c) =>
495
- resolveMapNode(c as unknown as JxMutableNode, item),
496
- );
497
- }
498
-
499
- return result;
500
- }
501
-
502
- /**
503
- * Resolve a dot/slash-separated path on an object.
504
- *
505
- * @param {unknown} obj
506
- * @param {string} path
507
- * @returns {any}
508
- */
509
- function resolvePath(obj: unknown, path: string) {
510
- const parts = path.split(/[/.]/);
511
- let current = obj as JxMutableNode;
512
- for (const part of parts) {
513
- if (current == null) return undefined;
514
- current = current[part];
515
- }
516
- return current;
517
- }
518
-
519
- /**
520
- * Resolve a $ref string against a scope.
521
- *
522
- * @param {string} ref
523
- * @param {Record<string, unknown>} scope
524
- * @returns {unknown}
525
- */
526
- function resolveRef(ref: string, scope: Record<string, unknown>) {
527
- if (ref.startsWith("#/state/")) {
528
- return resolvePath(scope, ref.slice("#/state/".length));
529
- }
530
- return resolvePath(scope, ref);
531
- }
532
-
533
- // ─── Table conversion ───────────────────────────────────────────────────────
534
-
535
- /**
536
- * Convert a table element to mdast table node.
537
- *
538
- * @param {JxElement} node
539
- * @param {Map<string, JxElement>} componentDefs
540
- * @param {Record<string, unknown> | null} [scope]
541
- * @returns {MdastNode[]}
542
- */
543
- function convertTable(
544
- node: JxElement,
545
- componentDefs: Map<string, JxElement>,
546
- scope?: Record<string, unknown> | null,
547
- ) {
548
- // Flatten thead/tbody wrappers to get rows
549
- const rows = convertChildren(node, componentDefs, scope).filter((c) => c.type === "tableRow");
550
- if (rows.length === 0) return [];
551
- return [{ type: "table", children: rows }];
552
- }
553
-
554
- // ─── Text resolution ────────────────────────────────────────────────────────
555
-
556
- /**
557
- * Resolve text content, handling template strings if a scope is available.
558
- *
559
- * @param {unknown} value
560
- * @param {Record<string, unknown> | null} [scope]
561
- * @returns {string | null}
562
- */
563
- function resolveText(value: unknown, scope?: Record<string, unknown> | null) {
564
- if (value == null) return null;
565
- if (typeof value === "string") {
566
- if (scope && isTemplateString(value)) {
567
- const resolved = evaluateStaticTemplate(value, scope);
568
- return resolved != null ? String(resolved) : value;
569
- }
570
- return value;
571
- }
572
- return String(value);
573
- }
574
-
575
- // ─── HTML → mdast conversion ──────────────────────────────────────────────
576
-
577
- /**
578
- * Convert an HTML string to mdast nodes. Handles common block and inline elements from rendered
579
- * markdown content.
580
- *
581
- * @param {string} html
582
- * @returns {MdastNode[]}
583
- */
584
- function htmlToMdast(html: string) {
585
- const nodes: MdastNode[] = [];
586
-
587
- // Simple top-level block parser
588
- const parts = splitHtmlBlocks(html);
589
- for (const part of parts) {
590
- const trimmed = part.trim();
591
- if (!trimmed) continue;
592
- const parsed = parseHtmlElement(trimmed);
593
- if (parsed) nodes.push(...parsed);
594
- }
595
-
596
- return nodes;
597
- }
598
-
599
- /**
600
- * Split HTML into top-level block chunks.
601
- *
602
- * @param {string} html
603
- * @returns {string[]}
604
- */
605
- function splitHtmlBlocks(html: string) {
606
- const blocks: string[] = [];
607
- const trimmed = html.trim();
608
-
609
- // Simple regex: split on top-level block boundaries
610
- // Match each top-level element or bare text
611
- const pattern =
612
- /(<(?: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;
613
- let lastIdx = 0;
614
- let m;
615
- while ((m = pattern.exec(trimmed)) !== null) {
616
- if (m.index > lastIdx) {
617
- const between = trimmed.slice(lastIdx, m.index).trim();
618
- if (between) blocks.push(between);
619
- }
620
- blocks.push(m[0]);
621
- lastIdx = pattern.lastIndex;
622
- }
623
- if (lastIdx < trimmed.length) {
624
- const tail = trimmed.slice(lastIdx).trim();
625
- if (tail) blocks.push(tail);
626
- }
627
-
628
- return blocks;
629
- }
630
-
631
- /**
632
- * Parse a single HTML element string into mdast node(s).
633
- *
634
- * @param {string} html
635
- * @returns {MdastNode[] | null}
636
- */
637
- function parseHtmlElement(html: string) {
638
- // Heading
639
- const hMatch = html.match(/^<(h[1-6])(?:\s[^>]*)?>(.+?)<\/\1>$/is);
640
- if (hMatch) {
641
- const depth = parseInt(hMatch[1].slice(1), 10);
642
- const children = parseInlineHtml(hMatch[2]);
643
- return [{ type: "heading", depth, children }];
644
- }
645
-
646
- // Paragraph
647
- const pMatch = html.match(/^<p(?:\s[^>]*)?>(.+?)<\/p>$/is);
648
- if (pMatch) {
649
- const children = parseInlineHtml(pMatch[1]);
650
- if (children.length === 0) return null;
651
- return [{ type: "paragraph", children }];
652
- }
653
-
654
- // Horizontal rule
655
- if (/^<hr\s*\/?>$/i.test(html)) {
656
- return [{ type: "thematicBreak" }];
657
- }
658
-
659
- // Code block (pre > code)
660
- const preMatch = html.match(
661
- /^<pre(?:\s[^>]*)?>\s*<code(?:\s+class="language-(\w+)")?(?:\s[^>]*)?>([^]*?)<\/code>\s*<\/pre>$/is,
662
- );
663
- if (preMatch) {
664
- const lang = preMatch[1] ?? null;
665
- const value = decodeHtmlEntities(preMatch[2]);
666
- return [{ type: "code", lang, value }];
667
- }
668
-
669
- // Blockquote
670
- const bqMatch = html.match(/^<blockquote(?:\s[^>]*)?>([^]*?)<\/blockquote>$/is);
671
- if (bqMatch) {
672
- const inner = htmlToMdast(bqMatch[1]);
673
- const children = inner.map((c) =>
674
- c.type === "text" ? { type: "paragraph", children: [c] } : c,
675
- );
676
- return [{ type: "blockquote", children }];
677
- }
678
-
679
- // Unordered list
680
- const ulMatch = html.match(/^<ul(?:\s[^>]*)?>([^]*?)<\/ul>$/is);
681
- if (ulMatch) {
682
- const items = parseListItems(ulMatch[1]);
683
- if (items.length === 0) return null;
684
- return [{ type: "list", ordered: false, spread: false, children: items }];
685
- }
686
-
687
- // Ordered list
688
- const olMatch = html.match(/^<ol(?:\s[^>]*)?>([^]*?)<\/ol>$/is);
689
- if (olMatch) {
690
- const items = parseListItems(olMatch[1]);
691
- if (items.length === 0) return null;
692
- return [{ type: "list", ordered: true, spread: false, children: items }];
693
- }
694
-
695
- // Table
696
- const tableMatch = html.match(/^<table(?:\s[^>]*)?>([^]*?)<\/table>$/is);
697
- if (tableMatch) {
698
- return parseHtmlTable(tableMatch[1]);
699
- }
700
-
701
- // Wrapper elements (div, section, etc.) — unwrap
702
- const wrapperMatch = html.match(
703
- /^<(?:div|section|article|aside|figure|nav|header|footer|main)(?:\s[^>]*)?>([^]*?)<\/(?:div|section|article|aside|figure|nav|header|footer|main)>$/is,
704
- );
705
- if (wrapperMatch) {
706
- return htmlToMdast(wrapperMatch[1]);
707
- }
708
-
709
- // Bare text / inline content
710
- const text = stripHtmlTags(html).trim();
711
- if (text) return [{ type: "paragraph", children: parseInlineHtml(html) }];
712
-
713
- return null;
714
- }
715
-
716
- /**
717
- * Parse inline HTML content to mdast inline nodes.
718
- *
719
- * @param {string} html
720
- * @returns {MdastNode[]}
721
- */
722
- function parseInlineHtml(html: string) {
723
- const nodes: MdastNode[] = [];
724
- let pos = 0;
725
-
726
- while (pos < html.length) {
727
- const tagStart = html.indexOf("<", pos);
728
- if (tagStart === -1) {
729
- // Remaining text
730
- const text = decodeHtmlEntities(html.slice(pos));
731
- if (text.trim()) nodes.push({ type: "text", value: text });
732
- break;
733
- }
734
-
735
- // Text before tag
736
- if (tagStart > pos) {
737
- const text = decodeHtmlEntities(html.slice(pos, tagStart));
738
- if (text.trim()) nodes.push({ type: "text", value: text });
739
- }
740
-
741
- // Self-closing tags
742
- const brMatch = html.slice(tagStart).match(/^<br\s*\/?>/i);
743
- if (brMatch) {
744
- nodes.push({ type: "break" });
745
- pos = tagStart + brMatch[0].length;
746
- continue;
747
- }
748
-
749
- const imgMatch = html.slice(tagStart).match(/^<img(\s[^>]*?)\/?>/i);
750
- if (imgMatch) {
751
- const attrs = imgMatch[1] ?? "";
752
- const src = attrs.match(/src="([^"]*)"/)?.[1] ?? "";
753
- const alt = attrs.match(/alt="([^"]*)"/)?.[1] ?? "";
754
- nodes.push({ type: "image", url: decodeHtmlEntities(src), alt: decodeHtmlEntities(alt) });
755
- pos = tagStart + imgMatch[0].length;
756
- continue;
757
- }
758
-
759
- // Paired inline tags
760
- const openMatch = html.slice(tagStart).match(/^<(a|em|strong|del|code|b|i|s)(\s[^>]*)?>/);
761
- if (openMatch) {
762
- const tag = openMatch[1].toLowerCase();
763
- const attrs = openMatch[2] ?? "";
764
- const innerStart = tagStart + openMatch[0].length;
765
- const closeTag = `</${tag}>`;
766
- const closeIdx = findMatchingClose(html, innerStart, tag);
767
- if (closeIdx === -1) {
768
- // No matching close — treat as text
769
- pos = tagStart + 1;
770
- continue;
771
- }
772
- const inner = html.slice(innerStart, closeIdx);
773
- pos = closeIdx + closeTag.length;
774
-
775
- switch (tag) {
776
- case "a": {
777
- const href = attrs.match(/href="([^"]*)"/)?.[1] ?? "";
778
- const title = attrs.match(/title="([^"]*)"/)?.[1] ?? null;
779
- const children = parseInlineHtml(inner);
780
- if (children.length === 0)
781
- children.push({ type: "text", value: decodeHtmlEntities(inner) });
782
- nodes.push({ type: "link", url: decodeHtmlEntities(href), title, children });
783
- break;
784
- }
785
- case "em":
786
- case "i":
787
- nodes.push({ type: "emphasis", children: parseInlineHtml(inner) });
788
- break;
789
- case "strong":
790
- case "b":
791
- nodes.push({ type: "strong", children: parseInlineHtml(inner) });
792
- break;
793
- case "del":
794
- case "s":
795
- nodes.push({ type: "delete", children: parseInlineHtml(inner) });
796
- break;
797
- case "code":
798
- nodes.push({ type: "inlineCode", value: decodeHtmlEntities(inner) });
799
- break;
800
- }
801
- continue;
802
- }
803
-
804
- // Unknown tag — skip it
805
- const skipMatch = html.slice(tagStart).match(/^<[^>]*>/);
806
- if (skipMatch) {
807
- pos = tagStart + skipMatch[0].length;
808
- } else {
809
- pos = tagStart + 1;
810
- }
811
- }
812
-
813
- return nodes;
814
- }
815
-
816
- /**
817
- * Find the matching closing tag, handling nested same-name tags.
818
- *
819
- * @param {string} html
820
- * @param {number} start - Position after the opening tag
821
- * @param {string} tag - Tag name to match
822
- * @returns {number} Position of the matching closing tag, or -1
823
- */
824
- function findMatchingClose(html: string, start: number, tag: string) {
825
- let depth = 1;
826
- const openRe = new RegExp(`<${tag}[\\s>]`, "gi");
827
- const closeRe = new RegExp(`</${tag}>`, "gi");
828
- openRe.lastIndex = start;
829
- closeRe.lastIndex = start;
830
-
831
- while (depth > 0) {
832
- const openMatch = openRe.exec(html);
833
- const closeMatch = closeRe.exec(html);
834
-
835
- if (!closeMatch) return -1;
836
-
837
- if (openMatch && openMatch.index < closeMatch.index) {
838
- depth++;
839
- openRe.lastIndex = openMatch.index + openMatch[0].length;
840
- closeRe.lastIndex = closeMatch.index; // re-check this close
841
- } else {
842
- depth--;
843
- if (depth === 0) return closeMatch.index;
844
- }
845
- }
846
- return -1;
847
- }
848
-
849
- /**
850
- * Parse <li> elements from list HTML.
851
- *
852
- * @param {string} html
853
- * @returns {MdastNode[]}
854
- */
855
- function parseListItems(html: string) {
856
- const items: MdastNode[] = [];
857
- const liPattern = /<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi;
858
- let m;
859
- while ((m = liPattern.exec(html)) !== null) {
860
- const inner = m[1].trim();
861
- // Check for nested block content
862
- const innerNodes = /<(?:p|ul|ol|blockquote|pre)[\s>]/i.test(inner)
863
- ? htmlToMdast(inner)
864
- : [{ type: "paragraph", children: parseInlineHtml(inner) }];
865
- items.push({ type: "listItem", spread: false, children: innerNodes });
866
- }
867
- return items;
868
- }
869
-
870
- /**
871
- * Parse an HTML table to mdast table node.
872
- *
873
- * @param {string} html
874
- * @returns {MdastNode[]}
875
- */
876
- function parseHtmlTable(html: string) {
877
- const rows: MdastNode[] = [];
878
- const trPattern = /<tr(?:\s[^>]*)?>([\s\S]*?)<\/tr>/gi;
879
- let m;
880
- while ((m = trPattern.exec(html)) !== null) {
881
- const cellPattern = /<(?:th|td)(?:\s[^>]*)?>([\s\S]*?)<\/(?:th|td)>/gi;
882
- const cells: MdastNode[] = [];
883
- let c;
884
- while ((c = cellPattern.exec(m[1])) !== null) {
885
- cells.push({ type: "tableCell", children: parseInlineHtml(c[1]) });
886
- }
887
- if (cells.length > 0) rows.push({ type: "tableRow", children: cells });
888
- }
889
- if (rows.length === 0) return [];
890
- return [{ type: "table", children: rows }];
891
- }
892
-
893
- /**
894
- * Strip all HTML tags from a string.
895
- *
896
- * @param {string} html
897
- * @returns {string}
898
- */
899
- function stripHtmlTags(html: string) {
900
- return html.replace(/<[^>]+>/g, "");
901
- }
902
-
903
- /**
904
- * Decode common HTML entities.
905
- *
906
- * @param {string} str
907
- * @returns {string}
908
- */
909
- function decodeHtmlEntities(str: string) {
910
- return str
911
- .replace(/&amp;/g, "&")
912
- .replace(/&lt;/g, "<")
913
- .replace(/&gt;/g, ">")
914
- .replace(/&quot;/g, '"')
915
- .replace(/&#39;/g, "'")
916
- .replace(/&#36;/g, "$")
917
- .replace(/&nbsp;/g, " ");
918
- }
919
-
920
- // ─── Public API ─────────────────────────────────────────────────────────────
921
-
922
- /**
923
- * Compile a fully-resolved Jx document to clean markdown.
924
- *
925
- * @param {JxDocument} doc - Resolved Jx document (post layout, context, prototypes, templates)
926
- * @param {Map<string, JxElement>} [componentDefs] - Component definitions for inlining
927
- * @returns {{ content: string }}
928
- */
929
- export function compileMarkdown(
930
- doc: JxDocument,
931
- componentDefs: Map<string, JxElement> = new Map(),
932
- ) {
933
- if (!Array.isArray(doc.children) || doc.children.length === 0) {
934
- return { content: "" };
935
- }
936
-
937
- // Build scope from resolved state for any remaining template expressions
938
- const scope = doc.state ? buildInitialScope(doc.state, null) : null;
939
-
940
- // Convert to mdast
941
- const mdastChildren = doc.children.flatMap((child: JxElement | string) =>
942
- nodeToMdast(child, componentDefs, scope),
943
- );
944
-
945
- // Clean up: ensure block-level structure (no bare inline nodes at root)
946
- const cleaned: MdastNode[] = [];
947
- let inlineBuf: MdastNode[] = [];
948
-
949
- const flushInline = () => {
950
- if (inlineBuf.length > 0) {
951
- cleaned.push({ type: "paragraph", children: inlineBuf });
952
- inlineBuf = [];
953
- }
954
- };
955
-
956
- for (const node of mdastChildren) {
957
- if (isInlineType(node.type)) {
958
- inlineBuf.push(node);
959
- } else {
960
- flushInline();
961
- cleaned.push(node);
962
- }
963
- }
964
- flushInline();
965
-
966
- const mdast = { type: "root", children: cleaned } as unknown as import("mdast").Root;
967
-
968
- const md = unified()
969
- .use(remarkGfm)
970
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*", setext: false })
971
- .stringify(mdast);
972
-
973
- // Clean up excessive whitespace
974
- const content = md.replace(/\n{3,}/g, "\n\n").trim() + "\n";
975
-
976
- return { content };
977
- }