@jxsuite/parser 2.0.1 → 3.0.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/src/serialize.ts CHANGED
@@ -1,52 +1,83 @@
1
1
  /**
2
2
  * Jx Markdown Serializer — Browser-safe module
3
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:
4
+ * The single Jx → markdown serializer, replacing the studio's md-convert and the compiler's
5
+ * compile-markdown. Two modes over a shared tag map:
6
6
  *
7
- * - `roundtrip` (default) — lossless for everything it can express (a `tagName` chosen at render
8
- * time cannot be, and throws): YAML frontmatter from non-children doc keys,
9
- * non-markdown elements emitted as remark directives with collapsed dot-path
10
- * attributes. Inverse of `transpileJxMarkdown()`.
11
- * - `export` — lossy: strips all Jx decoration, unwraps wrapper tags, inlines custom
12
- * elements via injected component definitions, evaluates template strings via
13
- * injected hooks. Produces clean GFM with no directives or frontmatter.
7
+ * - `roundtrip` (default) — lossless for everything it can express (a `tagName` chosen at render time
8
+ * cannot be, and throws): YAML frontmatter from non-children doc keys, non-markdown elements
9
+ * emitted as remark directives with collapsed dot-path attributes. Inverse of
10
+ * `transpileJxMarkdown()`.
11
+ * - `export` — lossy: strips all Jx decoration, unwraps wrapper tags, inlines custom elements via
12
+ * injected component definitions, evaluates template strings via injected hooks. Produces clean
13
+ * GFM with no directives or frontmatter.
14
14
  *
15
- * Template evaluation is injected (the compiler passes its static-template evaluator)
16
- * so this module stays free of compiler dependencies and browser-safe.
15
+ * Template evaluation is injected (the compiler passes its static-template evaluator) so this
16
+ * module stays free of compiler dependencies and browser-safe.
17
17
  *
18
18
  * @module @jxsuite/parser/serialize
19
19
  * @license MIT
20
20
  */
21
21
 
22
22
  import { unified } from "unified";
23
- import { isRef, tagNameCandidates } from "@jxsuite/schema/guards";
23
+ import {
24
+ isJsonObject,
25
+ isNodeObject,
26
+ isNonNullObject,
27
+ isNumber,
28
+ isRef,
29
+ isString,
30
+ tagNameCandidates,
31
+ } from "@jxsuite/schema/guards";
32
+ import type { TagNameSlot } from "@jxsuite/schema/guards";
24
33
  import remarkStringify from "remark-stringify";
25
34
  import remarkDirective from "remark-directive";
26
35
  import remarkGfm from "remark-gfm";
27
- import { stringify as stringifyYaml } from "yaml";
36
+ import { stringify } from "yaml";
28
37
  import { htmlToJx } from "@jxsuite/markup/html-to-jx";
29
38
  import { expandDotPaths } from "./transpile.ts";
30
39
  import type {
40
+ JsonObject,
31
41
  JsonValue,
32
42
  JxAttributeValue,
33
43
  JxDocument,
44
+ JxDocumentValue,
34
45
  JxElement,
35
- JxMutableNode,
36
46
  JxStateDefinition,
37
47
  } from "@jxsuite/schema/types";
38
- import type { MdastNode, UnifiedProcessor } from "./types.ts";
39
- import type { Root } from "mdast";
48
+ import type { MdastNode, UnifiedPlugin, UnifiedProcessor } from "./types.ts";
40
49
 
41
50
  /** Static text content of a node — bound (`$ref`) text has no serializable form. */
42
51
  function textOf(el: JxElement | undefined): string | undefined {
43
- return el && typeof el.textContent === "string" ? el.textContent : undefined;
52
+ return el && isString(el.textContent) ? el.textContent : undefined;
53
+ }
54
+
55
+ /**
56
+ * A child slot as a document actually holds it. The schema says element or string, but a
57
+ * hand-edited document can leave a number or a null there, and every reader below tolerates both.
58
+ */
59
+ type ChildSlot = JxElement | string | number | null | undefined;
60
+
61
+ /** The child list of a node; the legacy whole-slot repeater (an object) has no list form here. */
62
+ function childList(el: JxElement): readonly (JxElement | string)[] {
63
+ return Array.isArray(el.children) ? el.children : [];
44
64
  }
45
65
 
46
66
  // ─── Markdown element sets ──────────────────────────────────────────────────
47
67
  // Source of truth for the `$studio.elements` block in Markdown.class.json — a parser
48
68
  // Test asserts the two stay in sync.
49
69
 
70
+ /**
71
+ * One tag's rule in `$studio.elements.nesting`: the content kinds it admits, or exactly which
72
+ * children.
73
+ */
74
+ interface MdNestingRule {
75
+ readonly block?: boolean;
76
+ readonly inline?: boolean;
77
+ readonly directive?: boolean;
78
+ readonly only?: readonly string[];
79
+ }
80
+
50
81
  export const MD_ELEMENTS = {
51
82
  block: [
52
83
  "h1",
@@ -94,7 +125,7 @@ export const MD_ELEMENTS = {
94
125
  thead: { only: ["tr"] },
95
126
  tr: { only: ["th", "td"] },
96
127
  ul: { only: ["li"] },
97
- } as Record<string, { block?: boolean; inline?: boolean; directive?: boolean; only?: string[] }>,
128
+ } satisfies Readonly<Record<string, MdNestingRule>>,
98
129
  textOnly: ["code"],
99
130
  void: ["hr", "br", "img"],
100
131
  } as const;
@@ -113,7 +144,7 @@ export const MD_TEXT_ONLY: ReadonlySet<string> = new Set(MD_ELEMENTS.textOnly);
113
144
  // ─── Shared tag maps ────────────────────────────────────────────────────────
114
145
 
115
146
  /** Jx tagName → mdast node-type. */
116
- const TAG_MDAST_MAP: Record<string, string> = {
147
+ const TAG_MDAST_MAP = {
117
148
  a: "link",
118
149
  blockquote: "blockquote",
119
150
  br: "break",
@@ -141,7 +172,16 @@ const TAG_MDAST_MAP: Record<string, string> = {
141
172
  thead: "thead",
142
173
  tr: "tableRow",
143
174
  ul: "list",
144
- };
175
+ } as const;
176
+
177
+ /**
178
+ * Whether `tag` reads an entry of {@link TAG_MDAST_MAP}. This is the `in` operator, so an inherited
179
+ * name (`constructor`, `toString`) reads through too, exactly as the plain property lookup it
180
+ * replaces did; such a value matches no mdast case and exports nothing.
181
+ */
182
+ function hasMdastType(tag: string): tag is keyof typeof TAG_MDAST_MAP {
183
+ return tag in TAG_MDAST_MAP;
184
+ }
145
185
 
146
186
  /** Wrapper tags unwrapped in export mode — their children are promoted. */
147
187
  const WRAPPER_TAGS = new Set([
@@ -184,14 +224,22 @@ function isInlineType(type: string) {
184
224
 
185
225
  /** Read a fenced-code child's language. Canonical: `className`; legacy: `attributes.class`. */
186
226
  function codeLang(codeChild: JxElement | undefined): string | null {
187
- const cls = codeChild?.className ?? (codeChild?.attributes?.class as string | undefined) ?? "";
227
+ const cls = codeChild?.className ?? codeChild?.attributes?.class ?? "";
188
228
  const lang = String(cls).replace("language-", "");
189
229
  return lang || null;
190
230
  }
191
231
 
192
232
  // ─── Options ────────────────────────────────────────────────────────────────
193
233
 
194
- export interface SerializeOptions {
234
+ /** What a host's template evaluator substitutes: a scalar, or `undefined` to keep the original. */
235
+ export type TemplateSubstitution = string | number | boolean | undefined;
236
+
237
+ /**
238
+ * Serializer options. `ScopeValue` is whatever the host's `buildScope` puts in a scope (document
239
+ * values unless the host says otherwise): the serializer relays that scope to the same host's
240
+ * `evaluateTemplate` untouched, and otherwise only reads a repeater's `items` out of it.
241
+ */
242
+ export interface SerializeOptions<ScopeValue = JxDocumentValue> {
195
243
  /** `roundtrip` (default; lossless where expressible) or `export` (lossy clean markdown). */
196
244
  mode?: "roundtrip" | "export";
197
245
  /** Export mode: component definitions for inlining custom elements. */
@@ -201,9 +249,9 @@ export interface SerializeOptions {
201
249
  * original string. Hosts inject their own evaluator (the compiler passes its static-template
202
250
  * machinery); default keeps templates verbatim.
203
251
  */
204
- evaluateTemplate?: (value: string, scope: Record<string, unknown>) => unknown;
252
+ evaluateTemplate?: (value: string, scope: Record<string, ScopeValue>) => TemplateSubstitution;
205
253
  /** Export mode: build an evaluation scope from state definitions. */
206
- buildScope?: (state: Record<string, JxStateDefinition>) => Record<string, unknown> | null;
254
+ buildScope?: (state: Record<string, JxStateDefinition>) => Record<string, ScopeValue> | null;
207
255
  /** Roundtrip mode: markdown-native tag set; anything else becomes a directive. */
208
256
  allowlist?: ReadonlySet<string> | string[];
209
257
  /** Roundtrip mode: emit YAML frontmatter from non-children doc keys (default true). */
@@ -215,7 +263,7 @@ export interface SerializeOptions {
215
263
  // ═════════════════════════════════════════════════════════════════════════════
216
264
 
217
265
  /** Mdast node-type → Jx tagName mapping (canvas-targeted; text → span). */
218
- const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
266
+ const MDAST_TAG_MAP = {
219
267
  blockquote: () => "blockquote",
220
268
  break: () => "br",
221
269
  code: () => "pre",
@@ -234,25 +282,55 @@ const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
234
282
  tableRow: () => "tr",
235
283
  text: () => "span",
236
284
  thematicBreak: () => "hr",
237
- };
285
+ } satisfies Readonly<Record<string, (n: MdastNode) => string>>;
286
+
287
+ /**
288
+ * Whether `type` reads an entry of {@link MDAST_TAG_MAP}. Like {@link hasMdastType} this is the
289
+ * `in` operator, which sees inherited names as the property lookup it replaces did.
290
+ */
291
+ function hasJxTagName(type: string): type is keyof typeof MDAST_TAG_MAP {
292
+ return type in MDAST_TAG_MAP;
293
+ }
294
+
295
+ /**
296
+ * An mdast root: the tree {@link jxToMdast} builds and {@link mdastToJx} always converts to an
297
+ * element.
298
+ */
299
+ export interface MdastRoot extends MdastNode {
300
+ type: "root";
301
+ children: MdastNode[];
302
+ }
238
303
 
239
304
  /**
240
305
  * Convert an mdast tree to a Jx element tree (canvas-targeted: text nodes become spans). Inverse of
241
- * {@link jxToMdast}.
306
+ * {@link jxToMdast}. A root always converts to an element; any other node converts to what it maps
307
+ * to, which is `null` for a node type with no Jx form and a string for raw HTML that is only text.
242
308
  */
243
- export function mdastToJx(mdast: MdastNode): JxElement {
309
+ export function mdastToJx(mdast: MdastRoot): JxElement;
310
+ export function mdastToJx(mdast: MdastNode): JxElement | string | null;
311
+ export function mdastToJx(mdast: MdastNode): JxElement | string | null {
244
312
  if (mdast.type === "root") {
245
313
  return {
246
- children: (mdast.children ?? [])
247
- .filter((n) => n.type !== "yaml" && n.type !== "toml")
248
- .flatMap((n) => convertMdastNode(n))
249
- .filter(Boolean) as (JxElement | string)[],
314
+ children: convertMdastChildren(
315
+ (mdast.children ?? []).filter((n) => n.type !== "yaml" && n.type !== "toml"),
316
+ ),
250
317
  };
251
318
  }
252
- return convertMdastNode(mdast) as JxElement;
319
+ return convertMdastNode(mdast);
320
+ }
321
+
322
+ /**
323
+ * Convert mdast children, dropping the ones with no Jx form. A hand-built tree can carry holes
324
+ * (`null`) among them, and those are dropped too.
325
+ */
326
+ function convertMdastChildren(nodes: readonly (MdastNode | null)[]): (JxElement | string)[] {
327
+ return nodes.flatMap((n) => {
328
+ const converted = convertMdastNode(n);
329
+ return converted ? [converted] : [];
330
+ });
253
331
  }
254
332
 
255
- function convertMdastNode(node: MdastNode): JxElement | null {
333
+ function convertMdastNode(node: MdastNode | null): JxElement | string | null {
256
334
  if (!node) {
257
335
  return null;
258
336
  }
@@ -270,10 +348,11 @@ function convertMdastNode(node: MdastNode): JxElement | null {
270
348
  return null;
271
349
  }
272
350
  const nodes = htmlToJx(node.value);
273
- return nodes.length === 1 ? (nodes[0] as JxElement) : { children: nodes, tagName: "div" };
351
+ const [only] = nodes;
352
+ return nodes.length === 1 && only !== undefined ? only : { children: nodes, tagName: "div" };
274
353
  }
275
354
 
276
- const tagFn = MDAST_TAG_MAP[node.type];
355
+ const tagFn = hasJxTagName(node.type) ? MDAST_TAG_MAP[node.type] : undefined;
277
356
  if (!tagFn) {
278
357
  return null;
279
358
  }
@@ -281,11 +360,7 @@ function convertMdastNode(node: MdastNode): JxElement | null {
281
360
  const tag = tagFn(node);
282
361
  const el: JxElement = { tagName: tag };
283
362
 
284
- const childNodes = () =>
285
- (node.children ?? []).flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
286
- | JxElement
287
- | string
288
- )[];
363
+ const childNodes = () => convertMdastChildren(node.children ?? []);
289
364
  const flattenOrChildren = () => {
290
365
  if (node.children?.length === 1 && node.children[0]!.type === "text") {
291
366
  el.textContent = node.children[0]!.value ?? null;
@@ -348,13 +423,11 @@ function convertMdastNode(node: MdastNode): JxElement | null {
348
423
  }
349
424
 
350
425
  case "code": {
351
- el.children = [
352
- {
353
- tagName: "code",
354
- textContent: node.value ?? null,
355
- ...(node.lang ? { className: `language-${node.lang}` } : {}),
356
- },
357
- ];
426
+ const code: JxElement = { tagName: "code", textContent: node.value ?? null };
427
+ if (node.lang) {
428
+ code.className = `language-${node.lang}`;
429
+ }
430
+ el.children = [code];
358
431
  break;
359
432
  }
360
433
 
@@ -364,19 +437,15 @@ function convertMdastNode(node: MdastNode): JxElement | null {
364
437
  }
365
438
 
366
439
  case "table": {
367
- const rows = childNodes() as JxElement[];
368
- const thead =
369
- rows.length > 0
370
- ? { children: [rows[0]] as (JxElement | string)[], tagName: "thead" }
371
- : null;
372
- const tbody =
373
- rows.length > 1
374
- ? {
375
- children: rows.slice(1) as (JxElement | string)[],
376
- tagName: "tbody",
377
- }
378
- : null;
379
- el.children = [thead, tbody].filter(Boolean) as (JxElement | string)[];
440
+ const [headRow, ...bodyRows] = childNodes();
441
+ const sections: JxElement[] = [];
442
+ if (headRow !== undefined) {
443
+ sections.push({ children: [headRow], tagName: "thead" });
444
+ }
445
+ if (bodyRows.length > 0) {
446
+ sections.push({ children: bodyRows, tagName: "tbody" });
447
+ }
448
+ el.children = sections;
380
449
  break;
381
450
  }
382
451
  default: {
@@ -390,7 +459,7 @@ function convertMdastNode(node: MdastNode): JxElement | null {
390
459
  function convertDirective(node: MdastNode): JxElement {
391
460
  // Prototype directive (e.g. `:::Array`) → `{ $prototype: name, ... }`, tagName dropped.
392
461
  if (node.name && PROTOTYPE_DIRECTIVE_NAMES.has(node.name)) {
393
- return prototypeDirectiveToJx(node);
462
+ return prototypeDirectiveToJx(node, node.name);
394
463
  }
395
464
 
396
465
  const el: JxElement = { tagName: node.name ?? "div" };
@@ -401,16 +470,10 @@ function convertDirective(node: MdastNode): JxElement {
401
470
  if (node.children?.length === 1 && node.children[0]!.type === "text") {
402
471
  el.textContent = node.children[0]!.value ?? null;
403
472
  } else if (node.children?.length) {
404
- el.children = node.children.flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
405
- | JxElement
406
- | string
407
- )[];
473
+ el.children = convertMdastChildren(node.children);
408
474
  }
409
475
  } else if (node.type === "containerDirective" && node.children?.length) {
410
- el.children = node.children.flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
411
- | JxElement
412
- | string
413
- )[];
476
+ el.children = convertMdastChildren(node.children);
414
477
  }
415
478
  return el;
416
479
  }
@@ -419,25 +482,23 @@ function convertDirective(node: MdastNode): JxElement {
419
482
  * Roundtrip md → Jx for a prototype directive (e.g. `:::Array`): name → `$prototype` (tagName
420
483
  * dropped), dot-path attributes expanded (items/filter/sort), single nested child → `map`.
421
484
  */
422
- function prototypeDirectiveToJx(node: MdastNode): JxElement {
423
- const el: JxElement = { $prototype: node.name as string };
485
+ function prototypeDirectiveToJx(node: MdastNode, name: string): JxElement {
486
+ const el: JxElement = { $prototype: name };
424
487
  if (node.attributes && Object.keys(node.attributes).length > 0) {
425
488
  const expanded = expandDotPaths(node.attributes);
426
489
  for (const [key, value] of Object.entries(expanded)) {
427
490
  if (key === "$prototype") {
428
491
  continue;
429
492
  }
493
+ /* SAFETY: expandDotPaths nests the directive's attribute strings under their dot-path
494
+ segments, so every value it returns is a string or an object of those: JSON. */
430
495
  el[key] = value as JsonValue;
431
496
  }
432
497
  }
433
498
  if (node.children?.length) {
434
- const children = node.children.flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
435
- | JxElement
436
- | string
437
- )[];
438
- const template = children.find((c) => c != null && typeof c === "object");
499
+ const template = convertMdastChildren(node.children).find((child) => isNodeObject(child));
439
500
  if (template) {
440
- el.map = template as JxElement;
501
+ el.map = template;
441
502
  }
442
503
  }
443
504
  return el;
@@ -449,14 +510,37 @@ function prototypeDirectiveToJx(node: MdastNode): JxElement {
449
510
  * Convert a Jx element tree to an mdast tree (lossless: non-markdown elements become directives).
450
511
  * Inverse of {@link mdastToJx}.
451
512
  */
452
- export function jxToMdast(jx: JxElement, opts: SerializeOptions = {}): MdastNode {
513
+ export function jxToMdast(
514
+ jx: JxElement,
515
+ opts: Pick<SerializeOptions, "allowlist"> = {},
516
+ ): MdastRoot {
453
517
  const allowlist = normalizeAllowlist(opts.allowlist);
454
- const childArray = Array.isArray(jx.children) ? jx.children : ([] as (JxElement | string)[]);
455
- const children = childArray
456
- .map((child) => convertJxNode(child, true, allowlist))
457
- .filter(Boolean) as MdastNode[];
518
+ return { children: convertJxChildren(childList(jx), true, allowlist), type: "root" };
519
+ }
520
+
521
+ /** Convert Jx children to mdast, dropping the ones with no mdast form. */
522
+ function convertJxChildren(
523
+ children: readonly ChildSlot[],
524
+ isBlock: boolean,
525
+ allowlist: ReadonlySet<string>,
526
+ ): MdastNode[] {
527
+ return children.flatMap((child) => {
528
+ const converted = convertJxNode(child, isBlock, allowlist);
529
+ return converted ? [converted] : [];
530
+ });
531
+ }
532
+
533
+ /**
534
+ * An attribute as mdast text: a string as written, any other truthy value stringified (which is how
535
+ * remark-stringify prints it), and nothing else as empty.
536
+ */
537
+ function attributeText(value: JxAttributeValue | undefined): string {
538
+ return isString(value) ? value : value ? String(value) : "";
539
+ }
458
540
 
459
- return { children, type: "root" };
541
+ /** An attribute as an mdast `title`: like {@link attributeText}, but absent rather than empty. */
542
+ function attributeTitle(value: JxAttributeValue | undefined): string | null {
543
+ return isString(value) ? value : value ? String(value) : null;
460
544
  }
461
545
 
462
546
  function normalizeAllowlist(allowlist?: ReadonlySet<string> | string[]): ReadonlySet<string> {
@@ -484,16 +568,21 @@ function hasJxProps(el: JxElement) {
484
568
  }
485
569
 
486
570
  function convertJxNode(
487
- el: JxElement | string | number,
571
+ slot: JxDocumentValue | undefined,
488
572
  isBlock: boolean,
489
573
  allowlist: ReadonlySet<string>,
490
574
  ): MdastNode | null {
491
- if (typeof el === "string" || typeof el === "number") {
492
- return { type: "text", value: String(el) };
575
+ if (isString(slot) || isNumber(slot)) {
576
+ return { type: "text", value: String(slot) };
493
577
  }
494
- if (!el || typeof el !== "object") {
578
+ if (!isNonNullObject(slot)) {
495
579
  return null;
496
580
  }
581
+ /* SAFETY: `slot` is a non-null object. A child slot holds an element by contract; any other
582
+ object a hand-edited document left there (an array, a binding) is read below only through
583
+ JxElement's optional members and Object.entries, which every object answers, so it converts
584
+ the way it always has. */
585
+ const el = slot as JxElement;
497
586
 
498
587
  const tag = serializableTag(el.tagName) || "div";
499
588
 
@@ -502,7 +591,7 @@ function convertJxNode(
502
591
  return convertToDirective(el, isBlock, allowlist);
503
592
  }
504
593
 
505
- const mdastType = TAG_MDAST_MAP[tag];
594
+ const mdastType = hasMdastType(tag) ? TAG_MDAST_MAP[tag] : undefined;
506
595
  if (!mdastType) {
507
596
  return null;
508
597
  }
@@ -511,9 +600,7 @@ function convertJxNode(
511
600
  if (e.textContent != null) {
512
601
  return [{ type: "text", value: String(e.textContent) }];
513
602
  }
514
- return ((e.children ?? []) as (JxElement | string)[])
515
- .map((c) => convertJxNode(c, false, allowlist))
516
- .filter(Boolean) as MdastNode[];
603
+ return convertJxChildren(childList(e), false, allowlist);
517
604
  };
518
605
  const block = (e: JxElement): MdastNode[] => {
519
606
  if (e.textContent != null) {
@@ -524,9 +611,7 @@ function convertJxNode(
524
611
  },
525
612
  ];
526
613
  }
527
- return ((e.children ?? []) as (JxElement | string)[])
528
- .map((c) => convertJxNode(c, true, allowlist))
529
- .filter(Boolean) as MdastNode[];
614
+ return convertJxChildren(childList(e), true, allowlist);
530
615
  };
531
616
 
532
617
  switch (mdastType) {
@@ -559,18 +644,18 @@ function convertJxNode(
559
644
  case "link": {
560
645
  return {
561
646
  children: inline(el),
562
- title: (el.attributes?.title as string | null) ?? null,
647
+ title: attributeTitle(el.attributes?.title),
563
648
  type: "link",
564
- url: (el.attributes?.href as string) ?? "",
649
+ url: attributeText(el.attributes?.href),
565
650
  };
566
651
  }
567
652
 
568
653
  case "image": {
569
654
  return {
570
- alt: (el.attributes?.alt as string) ?? "",
571
- title: (el.attributes?.title as string | null) ?? null,
655
+ alt: attributeText(el.attributes?.alt),
656
+ title: attributeTitle(el.attributes?.title),
572
657
  type: "image",
573
- url: (el.attributes?.src as string) ?? "",
658
+ url: attributeText(el.attributes?.src),
574
659
  };
575
660
  }
576
661
 
@@ -580,12 +665,10 @@ function convertJxNode(
580
665
 
581
666
  case "list": {
582
667
  return {
583
- children: ((el.children ?? []) as (JxElement | string)[])
584
- .map((c) => convertJxNode(c, true, allowlist))
585
- .filter(Boolean) as MdastNode[],
668
+ children: convertJxChildren(childList(el), true, allowlist),
586
669
  ordered: tag === "ol",
587
670
  spread: false,
588
- start: tag === "ol" ? Math.trunc(Number(el.attributes?.start as string)) || 1 : null,
671
+ start: tag === "ol" ? Math.trunc(Number(el.attributes?.start)) || 1 : null,
589
672
  type: "list",
590
673
  };
591
674
  }
@@ -595,9 +678,8 @@ function convertJxNode(
595
678
  }
596
679
 
597
680
  case "code": {
598
- const codeChild = Array.isArray(el.children)
599
- ? (el.children[0] as JxElement | undefined)
600
- : undefined;
681
+ const [first] = childList(el);
682
+ const codeChild = isString(first) ? undefined : first;
601
683
  return {
602
684
  lang: codeLang(codeChild),
603
685
  type: "code",
@@ -616,12 +698,12 @@ function convertJxNode(
616
698
  case "table": {
617
699
  // Flatten thead/tbody back to rows
618
700
  const rows: MdastNode[] = [];
619
- for (const section of (el.children ?? []) as (JxElement | string)[]) {
620
- if (typeof section === "string") {
701
+ for (const section of childList(el)) {
702
+ if (isString(section)) {
621
703
  continue;
622
704
  }
623
705
  if (section.tagName === "thead" || section.tagName === "tbody") {
624
- for (const row of (section.children ?? []) as (JxElement | string)[]) {
706
+ for (const row of childList(section)) {
625
707
  const mdRow = convertJxNode(row, true, allowlist);
626
708
  if (mdRow) {
627
709
  if (section.tagName === "thead") {
@@ -639,9 +721,7 @@ function convertJxNode(
639
721
 
640
722
  case "tableRow": {
641
723
  return {
642
- children: ((el.children ?? []) as (JxElement | string)[])
643
- .map((c) => convertJxNode(c, false, allowlist))
644
- .filter(Boolean) as MdastNode[],
724
+ children: convertJxChildren(childList(el), false, allowlist),
645
725
  type: "tableRow",
646
726
  };
647
727
  }
@@ -700,11 +780,11 @@ const JX_DOLLAR_KEYS = new Set([
700
780
  * chosen tags today — the construct is an element-document (component) feature — so this refuses
701
781
  * rather than guesses, and says which candidates it saw.
702
782
  *
703
- * @param {unknown} tagName
783
+ * @param {TagNameSlot} tagName
704
784
  * @returns {string} The literal tag, or "" when none is declared
705
785
  */
706
- function serializableTag(tagName: unknown): string {
707
- if (typeof tagName === "string") {
786
+ function serializableTag(tagName: TagNameSlot): string {
787
+ if (isString(tagName)) {
708
788
  return tagName;
709
789
  }
710
790
  if (tagName == null) {
@@ -724,15 +804,18 @@ const PROTOTYPE_DIRECTIVE_NAMES = new Set(["Array"]);
724
804
 
725
805
  const JX_ANNOTATION_KEYS = new Set(["$title", "$description"]);
726
806
 
807
+ /** Element props (and attributes) bound for directive attributes, keyed as the element keys them. */
808
+ type DirectiveProps = Record<string, JxDocumentValue | undefined>;
809
+
727
810
  function collectDirectiveAttrs(el: JxElement) {
728
- const propsObj: Record<string, unknown> = {};
811
+ const propsObj: DirectiveProps = {};
729
812
 
730
813
  for (const [key, value] of Object.entries(el)) {
731
814
  if (key === "tagName" || key === "textContent" || key === "innerHTML" || key === "attributes") {
732
815
  continue;
733
816
  }
734
817
  if (key === "children") {
735
- if (value && typeof value === "object" && !Array.isArray(value)) {
818
+ if (isJsonObject(value)) {
736
819
  propsObj.children = value;
737
820
  }
738
821
  continue;
@@ -741,7 +824,7 @@ function collectDirectiveAttrs(el: JxElement) {
741
824
  }
742
825
 
743
826
  if (el.attributes) {
744
- for (const [key, value] of Object.entries(el.attributes as Record<string, unknown>)) {
827
+ for (const [key, value] of Object.entries(el.attributes)) {
745
828
  propsObj[key] = value;
746
829
  }
747
830
  }
@@ -756,15 +839,12 @@ function convertToDirective(
756
839
  ): MdastNode {
757
840
  // Prototype pseudo-element (e.g. Array repeater) with no tagName → directive named after the
758
841
  // Prototype; the `map` template is the directive body, items/filter/sort are attributes.
759
- if (
760
- !el.tagName &&
761
- typeof el.$prototype === "string" &&
762
- PROTOTYPE_DIRECTIVE_NAMES.has(el.$prototype)
763
- ) {
764
- return prototypeToDirective(el, isBlock, allowlist);
842
+ if (!el.tagName && isString(el.$prototype) && PROTOTYPE_DIRECTIVE_NAMES.has(el.$prototype)) {
843
+ return prototypeToDirective(el, el.$prototype, isBlock, allowlist);
765
844
  }
766
845
 
767
- const tag = (el.tagName as string) ?? "div";
846
+ // The caller has already refused a chosen tag (serializableTag), so a tag here is a literal.
847
+ const tag = isString(el.tagName) ? el.tagName : "div";
768
848
  const attrs = collectDirectiveAttrs(el);
769
849
 
770
850
  if (!isBlock) {
@@ -773,20 +853,14 @@ function convertToDirective(
773
853
  children:
774
854
  el.textContent != null
775
855
  ? [{ type: "text", value: String(el.textContent) }]
776
- : (((el.children ?? []) as (JxElement | string)[])
777
- .map((c) => convertJxNode(c, false, allowlist))
778
- .filter(Boolean) as MdastNode[]),
856
+ : convertJxChildren(childList(el), false, allowlist),
779
857
  name: tag,
780
858
  type: "textDirective",
781
859
  };
782
860
  }
783
861
 
784
- const rawChildren = el.children;
785
- const childrenIsObject =
786
- rawChildren && typeof rawChildren === "object" && !Array.isArray(rawChildren);
787
- const childArray = childrenIsObject
788
- ? undefined
789
- : (rawChildren as (JxElement | string)[] | undefined);
862
+ // A legacy whole-slot repeater (an object) has no child list to emit.
863
+ const childArray = Array.isArray(el.children) ? el.children : undefined;
790
864
  if (!childArray?.length && el.textContent == null) {
791
865
  return {
792
866
  attributes: attrs,
@@ -806,15 +880,11 @@ function convertToDirective(
806
880
  ];
807
881
  } else if (INLINE_CONTENT_TAGS.has(tag)) {
808
882
  // Inline content model: wrap children in one paragraph for continuous flow
809
- const inlineNodes = ((el.children ?? []) as (JxElement | string)[])
810
- .map((c) => convertJxNode(c, false, allowlist))
811
- .filter(Boolean) as MdastNode[];
883
+ const inlineNodes = convertJxChildren(childList(el), false, allowlist);
812
884
  directiveChildren =
813
885
  inlineNodes.length > 0 ? [{ children: inlineNodes, type: "paragraph" }] : [];
814
886
  } else {
815
- directiveChildren = ((el.children ?? []) as (JxElement | string)[])
816
- .map((c) => convertJxNode(c, true, allowlist))
817
- .filter(Boolean) as MdastNode[];
887
+ directiveChildren = convertJxChildren(childList(el), true, allowlist);
818
888
  }
819
889
 
820
890
  return {
@@ -833,10 +903,11 @@ function convertToDirective(
833
903
  */
834
904
  function prototypeToDirective(
835
905
  el: JxElement,
906
+ prototype: string,
836
907
  isBlock: boolean,
837
908
  allowlist: ReadonlySet<string>,
838
909
  ): MdastNode {
839
- const propsObj: Record<string, unknown> = {};
910
+ const propsObj: DirectiveProps = {};
840
911
  for (const [key, value] of Object.entries(el)) {
841
912
  if (key === "$prototype" || key === "map" || key === "tagName") {
842
913
  continue;
@@ -844,20 +915,20 @@ function prototypeToDirective(
844
915
  propsObj[key] = value;
845
916
  }
846
917
  const attrs = collapsePropsToAttrMap(propsObj);
847
- const mapNode = el.map ? convertJxNode(el.map as JxElement, isBlock, allowlist) : null;
918
+ const mapNode = el.map ? convertJxNode(el.map, isBlock, allowlist) : null;
848
919
  const children = mapNode ? [mapNode] : [];
849
920
  return {
850
921
  attributes: attrs,
851
922
  children,
852
- name: el.$prototype as string,
923
+ name: prototype,
853
924
  type: isBlock ? "containerDirective" : "textDirective",
854
925
  };
855
926
  }
856
927
 
857
- function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
928
+ function collapsePropsToAttrMap(propsObj: DirectiveProps) {
858
929
  const result: Record<string, string> = {};
859
930
 
860
- function walk(obj: Record<string, unknown>, prefix: string) {
931
+ function walk(obj: DirectiveProps, prefix: string) {
861
932
  for (const [key, value] of Object.entries(obj)) {
862
933
  let mdAttrKey = key;
863
934
  if (JX_DOLLAR_KEYS.has(key)) {
@@ -871,8 +942,8 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
871
942
  }
872
943
  if (key === "@") {
873
944
  // Bare "@" (no media name) — treat contents as base-level style props
874
- if (value && typeof value === "object" && !Array.isArray(value)) {
875
- walk(value as Record<string, unknown>, prefix);
945
+ if (isJsonObject(value)) {
946
+ walk(value, prefix);
876
947
  }
877
948
  continue;
878
949
  }
@@ -882,8 +953,8 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
882
953
 
883
954
  const fullKey = prefix ? `${prefix}.${mdAttrKey}` : mdAttrKey;
884
955
 
885
- if (value && typeof value === "object" && !Array.isArray(value)) {
886
- walk(value as Record<string, unknown>, fullKey);
956
+ if (isJsonObject(value)) {
957
+ walk(value, fullKey);
887
958
  } else {
888
959
  result[fullKey] = String(value);
889
960
  }
@@ -898,24 +969,29 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
898
969
  // Export mode — clean markdown (lossy, no directives)
899
970
  // ═════════════════════════════════════════════════════════════════════════════
900
971
 
901
- interface ExportContext {
972
+ interface ExportContext<ScopeValue> {
902
973
  componentDefs: Map<string, JxElement>;
903
- evaluateTemplate?: SerializeOptions["evaluateTemplate"];
904
- buildScope?: SerializeOptions["buildScope"];
974
+ evaluateTemplate?: SerializeOptions<ScopeValue>["evaluateTemplate"];
975
+ buildScope?: SerializeOptions<ScopeValue>["buildScope"];
905
976
  }
906
977
 
907
- function nodeToMdast(
908
- node: JxElement | string,
909
- ctx: ExportContext,
910
- scope?: Record<string, unknown> | null,
978
+ /**
979
+ * Export one child slot: a string or number as text, an element object as markdown. Anything else a
980
+ * hand-edited document or a repeater row left in the slot (null, a boolean, an array) exports
981
+ * nothing; an array would have reached the empty wrapper branch anyway.
982
+ */
983
+ function nodeToMdast<ScopeValue>(
984
+ node: JxDocumentValue | undefined,
985
+ ctx: ExportContext<ScopeValue>,
986
+ scope?: Record<string, ScopeValue> | null,
911
987
  ): MdastNode[] {
912
- if (typeof node === "string") {
988
+ if (isString(node)) {
913
989
  return node.trim() ? [{ type: "text", value: node }] : [];
914
990
  }
915
- if (typeof node === "number") {
991
+ if (isNumber(node)) {
916
992
  return [{ type: "text", value: String(node) }];
917
993
  }
918
- if (!node || typeof node !== "object") {
994
+ if (!isNodeObject(node)) {
919
995
  return [];
920
996
  }
921
997
 
@@ -928,7 +1004,7 @@ function nodeToMdast(
928
1004
  const text = resolveText(node.textContent, ctx, scope);
929
1005
 
930
1006
  // InnerHTML — convert HTML content to mdast
931
- if (typeof node.innerHTML === "string" && node.innerHTML.trim()) {
1007
+ if (isString(node.innerHTML) && node.innerHTML.trim()) {
932
1008
  const htmlNodes = htmlToMdast(node.innerHTML);
933
1009
  if (htmlNodes.length > 0) {
934
1010
  return htmlNodes;
@@ -948,7 +1024,7 @@ function nodeToMdast(
948
1024
  return exportChildren(node, ctx, scope);
949
1025
  }
950
1026
 
951
- const mdastType = TAG_MDAST_MAP[tag];
1027
+ const mdastType = hasMdastType(tag) ? TAG_MDAST_MAP[tag] : undefined;
952
1028
  if (!mdastType) {
953
1029
  if (text != null) {
954
1030
  return text.trim() ? [{ children: [{ type: "text", value: text }], type: "paragraph" }] : [];
@@ -987,7 +1063,7 @@ function nodeToMdast(
987
1063
 
988
1064
  case "link": {
989
1065
  const href = String(node.attributes?.href ?? "");
990
- const title = (node.attributes?.title as string | null) ?? null;
1066
+ const title = attributeTitle(node.attributes?.title);
991
1067
  const children =
992
1068
  text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
993
1069
  return [{ children, title, type: "link", url: href }];
@@ -996,7 +1072,7 @@ function nodeToMdast(
996
1072
  case "image": {
997
1073
  const src = String(node.attributes?.src ?? "");
998
1074
  const alt = String(node.attributes?.alt ?? "");
999
- const title = (node.attributes?.title as string | null) ?? null;
1075
+ const title = attributeTitle(node.attributes?.title);
1000
1076
  return [{ alt, title, type: "image", url: src }];
1001
1077
  }
1002
1078
 
@@ -1027,13 +1103,13 @@ function nodeToMdast(
1027
1103
  }
1028
1104
 
1029
1105
  case "code": {
1030
- const codeChild = Array.isArray(node.children)
1031
- ? node.children.find((c: JxElement | string) => (c as JxMutableNode)?.tagName === "code")
1032
- : null;
1033
- const value = textOf(codeChild as JxElement | undefined) ?? text ?? "";
1106
+ const codeChild = childList(node).find(
1107
+ (c): c is JxElement => isNodeObject(c) && c.tagName === "code",
1108
+ );
1109
+ const value = textOf(codeChild) ?? text ?? "";
1034
1110
  return [
1035
1111
  {
1036
- lang: codeLang(codeChild as JxElement | undefined),
1112
+ lang: codeLang(codeChild),
1037
1113
  type: "code",
1038
1114
  value,
1039
1115
  },
@@ -1085,10 +1161,10 @@ function nodeToMdast(
1085
1161
  return [];
1086
1162
  }
1087
1163
 
1088
- function exportChildren(
1164
+ function exportChildren<ScopeValue>(
1089
1165
  node: JxElement,
1090
- ctx: ExportContext,
1091
- scope?: Record<string, unknown> | null,
1166
+ ctx: ExportContext<ScopeValue>,
1167
+ scope?: Record<string, ScopeValue> | null,
1092
1168
  ): MdastNode[] {
1093
1169
  if (node.textContent != null) {
1094
1170
  const text = resolveText(node.textContent, ctx, scope);
@@ -1105,7 +1181,11 @@ function exportChildren(
1105
1181
 
1106
1182
  // ─── Export: component inlining ──────────────────────────────────────────────
1107
1183
 
1108
- function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): MdastNode[] {
1184
+ function inlineComponent<ScopeValue>(
1185
+ node: JxElement,
1186
+ tag: string,
1187
+ ctx: ExportContext<ScopeValue>,
1188
+ ): MdastNode[] {
1109
1189
  const def = ctx.componentDefs.get(tag);
1110
1190
  if (!def) {
1111
1191
  return exportChildren(node, ctx);
@@ -1118,14 +1198,9 @@ function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): Mdas
1118
1198
  if (key in stateDefs) {
1119
1199
  const existing = stateDefs[key];
1120
1200
  stateDefs[key] =
1121
- existing &&
1122
- typeof existing === "object" &&
1123
- !Array.isArray(existing) &&
1124
- "default" in existing
1125
- ? { ...existing, default: value }
1126
- : (value as JxStateDefinition);
1201
+ isJsonObject(existing) && "default" in existing ? { ...existing, default: value } : value;
1127
1202
  } else {
1128
- stateDefs[key] = value as JxStateDefinition;
1203
+ stateDefs[key] = value;
1129
1204
  }
1130
1205
  }
1131
1206
 
@@ -1140,43 +1215,52 @@ function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): Mdas
1140
1215
  const instanceChildren = node.children;
1141
1216
  return resolved.flatMap((child: JxElement | string): MdastNode[] => {
1142
1217
  // Replace slot elements with instance children
1143
- if (typeof child !== "string" && child?.tagName === "slot" && Array.isArray(instanceChildren)) {
1218
+ if (isNodeObject(child) && child.tagName === "slot" && Array.isArray(instanceChildren)) {
1144
1219
  return instanceChildren.flatMap((c: JxElement | string): MdastNode[] => nodeToMdast(c, ctx));
1145
1220
  }
1146
1221
  return nodeToMdast(child, ctx, scope);
1147
1222
  });
1148
1223
  }
1149
1224
 
1150
- function deepResolve(
1151
- nodes: (JxElement | string)[],
1152
- scope: Record<string, unknown>,
1153
- ctx: ExportContext,
1225
+ function deepResolve<ScopeValue>(
1226
+ nodes: readonly (JxElement | string)[],
1227
+ scope: Record<string, ScopeValue>,
1228
+ ctx: ExportContext<ScopeValue>,
1154
1229
  ) {
1155
1230
  return nodes.map((node) => resolveNode(node, scope, ctx));
1156
1231
  }
1157
1232
 
1158
- function resolveNode(
1233
+ /**
1234
+ * Evaluate the templates in one child slot. An element comes back as an evaluated copy and a string
1235
+ * as its evaluation; anything else a hand-edited document left in the slot comes back as it was.
1236
+ */
1237
+ function resolveNode<ScopeValue>(
1159
1238
  node: JxElement | string,
1160
- scope: Record<string, unknown>,
1161
- ctx: ExportContext,
1162
- ): JxElement | string {
1163
- if (typeof node === "string") {
1239
+ scope: Record<string, ScopeValue>,
1240
+ ctx: ExportContext<ScopeValue>,
1241
+ ): JxElement | string;
1242
+ function resolveNode<ScopeValue>(
1243
+ node: ChildSlot,
1244
+ scope: Record<string, ScopeValue>,
1245
+ ctx: ExportContext<ScopeValue>,
1246
+ ): ChildSlot {
1247
+ if (isString(node)) {
1164
1248
  const evaluated = ctx.evaluateTemplate?.(node, scope);
1165
1249
  return evaluated !== undefined ? String(evaluated) : node;
1166
1250
  }
1167
- if (!node || typeof node !== "object") {
1251
+ if (!isNonNullObject(node)) {
1168
1252
  return node;
1169
1253
  }
1170
1254
 
1171
1255
  const result = { ...node };
1172
1256
 
1173
- if (typeof result.textContent === "string") {
1257
+ if (isString(result.textContent)) {
1174
1258
  const evaluated = ctx.evaluateTemplate?.(result.textContent, scope);
1175
1259
  if (evaluated !== undefined) {
1176
1260
  result.textContent = String(evaluated);
1177
1261
  }
1178
1262
  }
1179
- if (typeof result.innerHTML === "string") {
1263
+ if (isString(result.innerHTML)) {
1180
1264
  const evaluated = ctx.evaluateTemplate?.(result.innerHTML, scope);
1181
1265
  if (evaluated !== undefined) {
1182
1266
  result.innerHTML = String(evaluated);
@@ -1185,9 +1269,9 @@ function resolveNode(
1185
1269
  if (result.attributes) {
1186
1270
  result.attributes = { ...result.attributes };
1187
1271
  for (const [k, v] of Object.entries(result.attributes)) {
1188
- if (typeof v === "string") {
1272
+ if (isString(v)) {
1189
1273
  // Template evaluation yields a substituted scalar for attribute values.
1190
- const evaluated = ctx.evaluateTemplate?.(v, scope) as JxAttributeValue | undefined;
1274
+ const evaluated = ctx.evaluateTemplate?.(v, scope);
1191
1275
  if (evaluated !== undefined) {
1192
1276
  result.attributes[k] = evaluated;
1193
1277
  }
@@ -1203,12 +1287,12 @@ function resolveNode(
1203
1287
 
1204
1288
  // ─── Export: array expansion ─────────────────────────────────────────────────
1205
1289
 
1206
- function expandArray(
1290
+ function expandArray<ScopeValue>(
1207
1291
  arrayDef: JxElement,
1208
- ctx: ExportContext,
1209
- scope?: Record<string, unknown> | null,
1292
+ ctx: ExportContext<ScopeValue>,
1293
+ scope?: Record<string, ScopeValue> | null,
1210
1294
  ): MdastNode[] {
1211
- const itemsValue = (arrayDef as JxMutableNode).items;
1295
+ const itemsValue = arrayDef.items;
1212
1296
  const itemsRef = isRef(itemsValue) ? itemsValue.$ref : undefined;
1213
1297
  if (!itemsRef || !scope) {
1214
1298
  return [];
@@ -1219,91 +1303,90 @@ function expandArray(
1219
1303
  return [];
1220
1304
  }
1221
1305
 
1222
- const mapTemplate = (arrayDef as JxMutableNode).map;
1306
+ const mapTemplate = arrayDef.map;
1223
1307
  if (!mapTemplate) {
1224
1308
  return [];
1225
1309
  }
1226
1310
 
1227
- return items.flatMap((item: JxMutableNode): MdastNode[] => {
1228
- const resolved = resolveMapNode(mapTemplate, item);
1229
- return nodeToMdast(resolved as JxElement | string, ctx, scope);
1311
+ return items.flatMap((row): MdastNode[] => {
1312
+ const resolved = isNodeObject(mapTemplate) ? resolveMapNode(mapTemplate, row) : mapTemplate;
1313
+ return nodeToMdast(resolved, ctx, scope);
1230
1314
  });
1231
1315
  }
1232
1316
 
1233
- function resolveMapNode(node: JxMutableNode, item: Record<string, unknown>) {
1234
- if (typeof node === "string") {
1235
- return node;
1236
- }
1237
- if (!node || typeof node !== "object") {
1238
- return node;
1239
- }
1240
-
1241
- const result = { ...node };
1317
+ /**
1318
+ * A copy of a repeater template element with its `$map/` references replaced by what they name in
1319
+ * `row`. A child that is not an element object (a string, a number, an array, which exports nothing
1320
+ * either way) is kept as it is.
1321
+ */
1322
+ function resolveMapNode(node: JxElement, row: JsonValue): JxElement {
1323
+ const result: JxElement = { ...node };
1242
1324
 
1243
1325
  for (const [key, value] of Object.entries(result)) {
1244
- if (value && typeof value === "object" && (value as JxMutableNode).$ref) {
1245
- const ref = (value as JxMutableNode).$ref as string;
1246
- if (ref.startsWith("$map/")) {
1247
- const path = ref.slice("$map/".length);
1248
- result[key] = resolvePath(
1249
- item,
1250
- path.startsWith("item/") ? path.slice("item/".length) : path,
1251
- );
1252
- }
1326
+ if (isRef(value) && value.$ref.startsWith("$map/")) {
1327
+ const path = value.$ref.slice("$map/".length);
1328
+ result[key] = resolvePath(row, path.startsWith("item/") ? path.slice("item/".length) : path);
1253
1329
  }
1254
1330
  }
1255
1331
 
1256
- if (result.$props) {
1257
- result.$props = resolveMapNode(
1258
- result.$props as unknown as JxMutableNode,
1259
- item,
1260
- ) as unknown as Record<string, JsonValue>;
1332
+ if (isNodeObject(result.$props)) {
1333
+ /* SAFETY: `$props` holds JSON, and resolveMapNode returns a copy that differs from it only where
1334
+ it substituted row data (JsonValue) or a nested value it resolved the same way, so the copy
1335
+ is still a JSON props object. */
1336
+ result.$props = resolveMapNode(result.$props, row) as Record<string, JsonValue>;
1261
1337
  }
1262
1338
 
1263
- if (typeof result.textContent === "string" && result.textContent.startsWith("$map/")) {
1264
- result.textContent = resolvePath(item, result.textContent.slice("$map/".length)) as unknown as
1339
+ if (isString(result.textContent) && result.textContent.startsWith("$map/")) {
1340
+ /* SAFETY: the row value can be any JSON (or undefined when the path misses), not just text.
1341
+ Every reader of a resolved template's textContent accepts that: resolveText treats null and
1342
+ undefined as no text and stringifies the rest, and textOf ignores non-strings. */
1343
+ result.textContent = resolvePath(row, result.textContent.slice("$map/".length)) as
1265
1344
  | string
1266
1345
  | null;
1267
1346
  }
1268
1347
 
1269
1348
  if (Array.isArray(result.children)) {
1270
- result.children = result.children.map((c) =>
1271
- resolveMapNode(c as unknown as JxMutableNode, item),
1272
- );
1349
+ result.children = result.children.map((c) => (isNodeObject(c) ? resolveMapNode(c, row) : c));
1273
1350
  }
1274
1351
 
1275
1352
  return result;
1276
1353
  }
1277
1354
 
1278
- function resolvePath(obj: unknown, path: string) {
1355
+ /** Walk `path` (steps split on `/` or `.`) from `root`; `undefined` once a step meets nothing. */
1356
+ function resolvePath(root: JsonValue | undefined, path: string): JsonValue | undefined {
1279
1357
  const parts = path.split(/[/.]/);
1280
- let current = obj as JxMutableNode;
1358
+ let current = root;
1281
1359
  for (const part of parts) {
1282
1360
  if (current == null) {
1283
1361
  return;
1284
1362
  }
1285
- // Paths address nodes by construction; non-node leaves surface as undefined above.
1286
- current = current[part] as JxMutableNode;
1363
+ /* SAFETY: `current` is neither null nor undefined, and every other value answers a member read
1364
+ by name (a string's `length`, an array's index). A member of JSON data is JSON, which is all a
1365
+ state or `$map/` path is written to reach; a step naming a method reads the function, as it
1366
+ always has. */
1367
+ current = (current as Readonly<Partial<Record<string, JsonValue>>>)[part];
1287
1368
  }
1288
1369
  return current;
1289
1370
  }
1290
1371
 
1291
- function resolveRef(ref: string, scope: Record<string, unknown>) {
1292
- if (ref.startsWith("#/state/")) {
1293
- return resolvePath(scope, ref.slice("#/state/".length));
1294
- }
1295
- return resolvePath(scope, ref);
1372
+ function resolveRef<ScopeValue>(ref: string, scope: Record<string, ScopeValue>) {
1373
+ const path = ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref;
1374
+ /* SAFETY: a repeater's `items` reference names a state entry, and the host's buildScope keeps
1375
+ each entry under its name. State is declared in the JSON document, so the entry a repeater
1376
+ names holds JSON; a host value that is not (a callable) is only read by name, like any object,
1377
+ and then fails the Array.isArray check before anything uses it. */
1378
+ return resolvePath(scope as JsonObject, path);
1296
1379
  }
1297
1380
 
1298
- function resolveText(
1299
- value: unknown,
1300
- ctx: ExportContext,
1301
- scope?: Record<string, unknown> | null,
1381
+ function resolveText<ScopeValue>(
1382
+ value: JxDocumentValue | undefined,
1383
+ ctx: ExportContext<ScopeValue>,
1384
+ scope?: Record<string, ScopeValue> | null,
1302
1385
  ): string | null {
1303
1386
  if (value == null) {
1304
1387
  return null;
1305
1388
  }
1306
- if (typeof value === "string") {
1389
+ if (isString(value)) {
1307
1390
  if (scope) {
1308
1391
  const evaluated = ctx.evaluateTemplate?.(value, scope);
1309
1392
  if (evaluated !== undefined) {
@@ -1635,7 +1718,10 @@ function decodeHtmlEntities(str: string) {
1635
1718
  * - `mode: "export"` — clean GFM: Jx decoration stripped, wrappers unwrapped, custom elements inlined
1636
1719
  * via `componentDefs`, templates evaluated via injected hooks.
1637
1720
  */
1638
- export function serializeJxMarkdown(doc: JxDocument, opts: SerializeOptions = {}): string {
1721
+ export function serializeJxMarkdown<ScopeValue>(
1722
+ doc: JxDocument,
1723
+ opts: SerializeOptions<ScopeValue> = {},
1724
+ ): string {
1639
1725
  const mode = opts.mode ?? "roundtrip";
1640
1726
 
1641
1727
  if (mode === "export") {
@@ -1644,11 +1730,28 @@ export function serializeJxMarkdown(doc: JxDocument, opts: SerializeOptions = {}
1644
1730
  return serializeRoundtrip(doc, opts);
1645
1731
  }
1646
1732
 
1647
- function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
1733
+ /* SAFETY: remark-stringify default-exports a unified attacher taking optional JSON settings (the
1734
+ bullet/emphasis/strong/setext options passed below), which is the UnifiedPlugin shape. Its
1735
+ declared type is unified's `Plugin`, and the root types.d.ts shadows "unified" with a shorthand
1736
+ `declare module`, so the type-aware linter sees this import as an error type, not that Plugin. */
1737
+ const remarkStringifyPlugin = remarkStringify as UnifiedPlugin;
1738
+
1739
+ /** A fresh unified processor, seen through the surface this module drives. */
1740
+ function createProcessor(): UnifiedProcessor {
1741
+ /* SAFETY: unified() returns a Processor, which has the use/parse/runSync/stringify members
1742
+ UnifiedProcessor declares; that interface exists only because oxlint cannot resolve unified's
1743
+ own ESM types (see types.ts). */
1744
+ return (unified as () => UnifiedProcessor)();
1745
+ }
1746
+
1747
+ function serializeRoundtrip<ScopeValue>(
1748
+ doc: JxDocument,
1749
+ opts: SerializeOptions<ScopeValue>,
1750
+ ): string {
1648
1751
  const lines: string[] = [];
1649
1752
 
1650
1753
  if (opts.frontmatter !== false) {
1651
- const frontmatter: Record<string, unknown> = {};
1754
+ const frontmatter: DirectiveProps = {};
1652
1755
  for (const [key, value] of Object.entries(doc)) {
1653
1756
  if (key === "children") {
1654
1757
  continue;
@@ -1657,17 +1760,17 @@ function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
1657
1760
  }
1658
1761
 
1659
1762
  if (Object.keys(frontmatter).length > 0) {
1660
- lines.push("---", stringifyYaml(frontmatter).trim(), "---", "");
1763
+ lines.push("---", stringify(frontmatter).trim(), "---", "");
1661
1764
  }
1662
1765
  }
1663
1766
 
1664
1767
  if (Array.isArray(doc.children) && doc.children.length > 0) {
1665
- const mdast = jxToMdast(doc as JxElement, opts);
1666
- const processor = (unified as unknown as () => UnifiedProcessor)()
1768
+ const mdast = jxToMdast(doc, opts);
1769
+ const processor = createProcessor()
1667
1770
  .use(remarkGfm)
1668
1771
  .use(remarkDirective)
1669
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" });
1670
- const md = processor.stringify(mdast as unknown as Root);
1772
+ .use(remarkStringifyPlugin, { bullet: "-", emphasis: "*", strong: "*" });
1773
+ const md = processor.stringify(mdast);
1671
1774
 
1672
1775
  lines.push(md);
1673
1776
  }
@@ -1678,12 +1781,12 @@ function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
1678
1781
  .trim()}\n`;
1679
1782
  }
1680
1783
 
1681
- function serializeExport(doc: JxDocument, opts: SerializeOptions): string {
1784
+ function serializeExport<ScopeValue>(doc: JxDocument, opts: SerializeOptions<ScopeValue>): string {
1682
1785
  if (!Array.isArray(doc.children) || doc.children.length === 0) {
1683
1786
  return "";
1684
1787
  }
1685
1788
 
1686
- const ctx: ExportContext = {
1789
+ const ctx: ExportContext<ScopeValue> = {
1687
1790
  buildScope: opts.buildScope,
1688
1791
  componentDefs: opts.componentDefs ?? new Map<string, JxElement>(),
1689
1792
  evaluateTemplate: opts.evaluateTemplate,
@@ -1717,19 +1820,17 @@ function serializeExport(doc: JxDocument, opts: SerializeOptions): string {
1717
1820
  }
1718
1821
  flushInline();
1719
1822
 
1720
- const mdast = {
1823
+ const mdast: MdastNode = {
1721
1824
  children: cleaned,
1722
1825
  type: "root",
1723
- } as unknown as Root;
1724
-
1725
- const processor = (unified as unknown as () => UnifiedProcessor)()
1726
- .use(remarkGfm)
1727
- .use(remarkStringify, {
1728
- bullet: "-",
1729
- emphasis: "*",
1730
- setext: false,
1731
- strong: "*",
1732
- });
1826
+ };
1827
+
1828
+ const processor = createProcessor().use(remarkGfm).use(remarkStringifyPlugin, {
1829
+ bullet: "-",
1830
+ emphasis: "*",
1831
+ setext: false,
1832
+ strong: "*",
1833
+ });
1733
1834
  const md = processor.stringify(mdast);
1734
1835
 
1735
1836
  return `${md.replaceAll(/\n{3,}/g, "\n\n").trim()}\n`;