@jxsuite/parser 2.0.1 → 4.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,86 @@
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";
33
+ import remarkParse from "remark-parse";
24
34
  import remarkStringify from "remark-stringify";
25
35
  import remarkDirective from "remark-directive";
26
36
  import remarkGfm from "remark-gfm";
27
- import { stringify as stringifyYaml } from "yaml";
37
+ import { stringify } from "yaml";
28
38
  import { htmlToJx } from "@jxsuite/markup/html-to-jx";
29
39
  import { expandDotPaths } from "./transpile.ts";
40
+ import { bodyText, frontmatterText, markdownMarkers, spliceFrontmatter } from "./source-style.ts";
41
+ import type { MarkdownMarkers } from "./source-style.ts";
30
42
  import type {
43
+ JsonObject,
31
44
  JsonValue,
32
45
  JxAttributeValue,
33
46
  JxDocument,
47
+ JxDocumentValue,
34
48
  JxElement,
35
- JxMutableNode,
36
49
  JxStateDefinition,
37
50
  } from "@jxsuite/schema/types";
38
- import type { MdastNode, UnifiedProcessor } from "./types.ts";
39
- import type { Root } from "mdast";
51
+ import type { MdastNode, UnifiedPlugin, UnifiedProcessor } from "./types.ts";
40
52
 
41
53
  /** Static text content of a node — bound (`$ref`) text has no serializable form. */
42
54
  function textOf(el: JxElement | undefined): string | undefined {
43
- return el && typeof el.textContent === "string" ? el.textContent : undefined;
55
+ return el && isString(el.textContent) ? el.textContent : undefined;
56
+ }
57
+
58
+ /**
59
+ * A child slot as a document actually holds it. The schema says element or string, but a
60
+ * hand-edited document can leave a number or a null there, and every reader below tolerates both.
61
+ */
62
+ type ChildSlot = JxElement | string | number | null | undefined;
63
+
64
+ /** The child list of a node; the legacy whole-slot repeater (an object) has no list form here. */
65
+ function childList(el: JxElement): readonly (JxElement | string)[] {
66
+ return Array.isArray(el.children) ? el.children : [];
44
67
  }
45
68
 
46
69
  // ─── Markdown element sets ──────────────────────────────────────────────────
47
70
  // Source of truth for the `$studio.elements` block in Markdown.class.json — a parser
48
71
  // Test asserts the two stay in sync.
49
72
 
73
+ /**
74
+ * One tag's rule in `$studio.elements.nesting`: the content kinds it admits, or exactly which
75
+ * children.
76
+ */
77
+ interface MdNestingRule {
78
+ readonly block?: boolean;
79
+ readonly inline?: boolean;
80
+ readonly directive?: boolean;
81
+ readonly only?: readonly string[];
82
+ }
83
+
50
84
  export const MD_ELEMENTS = {
51
85
  block: [
52
86
  "h1",
@@ -94,7 +128,7 @@ export const MD_ELEMENTS = {
94
128
  thead: { only: ["tr"] },
95
129
  tr: { only: ["th", "td"] },
96
130
  ul: { only: ["li"] },
97
- } as Record<string, { block?: boolean; inline?: boolean; directive?: boolean; only?: string[] }>,
131
+ } satisfies Readonly<Record<string, MdNestingRule>>,
98
132
  textOnly: ["code"],
99
133
  void: ["hr", "br", "img"],
100
134
  } as const;
@@ -113,7 +147,7 @@ export const MD_TEXT_ONLY: ReadonlySet<string> = new Set(MD_ELEMENTS.textOnly);
113
147
  // ─── Shared tag maps ────────────────────────────────────────────────────────
114
148
 
115
149
  /** Jx tagName → mdast node-type. */
116
- const TAG_MDAST_MAP: Record<string, string> = {
150
+ const TAG_MDAST_MAP = {
117
151
  a: "link",
118
152
  blockquote: "blockquote",
119
153
  br: "break",
@@ -141,7 +175,16 @@ const TAG_MDAST_MAP: Record<string, string> = {
141
175
  thead: "thead",
142
176
  tr: "tableRow",
143
177
  ul: "list",
144
- };
178
+ } as const;
179
+
180
+ /**
181
+ * Whether `tag` reads an entry of {@link TAG_MDAST_MAP}. This is the `in` operator, so an inherited
182
+ * name (`constructor`, `toString`) reads through too, exactly as the plain property lookup it
183
+ * replaces did; such a value matches no mdast case and exports nothing.
184
+ */
185
+ function hasMdastType(tag: string): tag is keyof typeof TAG_MDAST_MAP {
186
+ return tag in TAG_MDAST_MAP;
187
+ }
145
188
 
146
189
  /** Wrapper tags unwrapped in export mode — their children are promoted. */
147
190
  const WRAPPER_TAGS = new Set([
@@ -184,14 +227,22 @@ function isInlineType(type: string) {
184
227
 
185
228
  /** Read a fenced-code child's language. Canonical: `className`; legacy: `attributes.class`. */
186
229
  function codeLang(codeChild: JxElement | undefined): string | null {
187
- const cls = codeChild?.className ?? (codeChild?.attributes?.class as string | undefined) ?? "";
230
+ const cls = codeChild?.className ?? codeChild?.attributes?.class ?? "";
188
231
  const lang = String(cls).replace("language-", "");
189
232
  return lang || null;
190
233
  }
191
234
 
192
235
  // ─── Options ────────────────────────────────────────────────────────────────
193
236
 
194
- export interface SerializeOptions {
237
+ /** What a host's template evaluator substitutes: a scalar, or `undefined` to keep the original. */
238
+ export type TemplateSubstitution = string | number | boolean | undefined;
239
+
240
+ /**
241
+ * Serializer options. `ScopeValue` is whatever the host's `buildScope` puts in a scope (document
242
+ * values unless the host says otherwise): the serializer relays that scope to the same host's
243
+ * `evaluateTemplate` untouched, and otherwise only reads a repeater's `items` out of it.
244
+ */
245
+ export interface SerializeOptions<ScopeValue = JxDocumentValue> {
195
246
  /** `roundtrip` (default; lossless where expressible) or `export` (lossy clean markdown). */
196
247
  mode?: "roundtrip" | "export";
197
248
  /** Export mode: component definitions for inlining custom elements. */
@@ -201,13 +252,19 @@ export interface SerializeOptions {
201
252
  * original string. Hosts inject their own evaluator (the compiler passes its static-template
202
253
  * machinery); default keeps templates verbatim.
203
254
  */
204
- evaluateTemplate?: (value: string, scope: Record<string, unknown>) => unknown;
255
+ evaluateTemplate?: (value: string, scope: Record<string, ScopeValue>) => TemplateSubstitution;
205
256
  /** Export mode: build an evaluation scope from state definitions. */
206
- buildScope?: (state: Record<string, JxStateDefinition>) => Record<string, unknown> | null;
257
+ buildScope?: (state: Record<string, JxStateDefinition>) => Record<string, ScopeValue> | null;
207
258
  /** Roundtrip mode: markdown-native tag set; anything else becomes a directive. */
208
259
  allowlist?: ReadonlySet<string> | string[];
209
260
  /** Roundtrip mode: emit YAML frontmatter from non-children doc keys (default true). */
210
261
  frontmatter?: boolean;
262
+ /**
263
+ * Roundtrip mode: the text the document was read from. Every frontmatter key whose value is
264
+ * unchanged keeps its own spelling (quotes, wrapping, block style, comments), and the body is
265
+ * written with the emphasis, strong and bullet markers the source used (`source-style.ts`).
266
+ */
267
+ source?: string;
211
268
  }
212
269
 
213
270
  // ═════════════════════════════════════════════════════════════════════════════
@@ -215,7 +272,7 @@ export interface SerializeOptions {
215
272
  // ═════════════════════════════════════════════════════════════════════════════
216
273
 
217
274
  /** Mdast node-type → Jx tagName mapping (canvas-targeted; text → span). */
218
- const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
275
+ const MDAST_TAG_MAP = {
219
276
  blockquote: () => "blockquote",
220
277
  break: () => "br",
221
278
  code: () => "pre",
@@ -234,25 +291,55 @@ const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
234
291
  tableRow: () => "tr",
235
292
  text: () => "span",
236
293
  thematicBreak: () => "hr",
237
- };
294
+ } satisfies Readonly<Record<string, (n: MdastNode) => string>>;
295
+
296
+ /**
297
+ * Whether `type` reads an entry of {@link MDAST_TAG_MAP}. Like {@link hasMdastType} this is the
298
+ * `in` operator, which sees inherited names as the property lookup it replaces did.
299
+ */
300
+ function hasJxTagName(type: string): type is keyof typeof MDAST_TAG_MAP {
301
+ return type in MDAST_TAG_MAP;
302
+ }
303
+
304
+ /**
305
+ * An mdast root: the tree {@link jxToMdast} builds and {@link mdastToJx} always converts to an
306
+ * element.
307
+ */
308
+ export interface MdastRoot extends MdastNode {
309
+ type: "root";
310
+ children: MdastNode[];
311
+ }
238
312
 
239
313
  /**
240
314
  * Convert an mdast tree to a Jx element tree (canvas-targeted: text nodes become spans). Inverse of
241
- * {@link jxToMdast}.
315
+ * {@link jxToMdast}. A root always converts to an element; any other node converts to what it maps
316
+ * to, which is `null` for a node type with no Jx form and a string for raw HTML that is only text.
242
317
  */
243
- export function mdastToJx(mdast: MdastNode): JxElement {
318
+ export function mdastToJx(mdast: MdastRoot): JxElement;
319
+ export function mdastToJx(mdast: MdastNode): JxElement | string | null;
320
+ export function mdastToJx(mdast: MdastNode): JxElement | string | null {
244
321
  if (mdast.type === "root") {
245
322
  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)[],
323
+ children: convertMdastChildren(
324
+ (mdast.children ?? []).filter((n) => n.type !== "yaml" && n.type !== "toml"),
325
+ ),
250
326
  };
251
327
  }
252
- return convertMdastNode(mdast) as JxElement;
328
+ return convertMdastNode(mdast);
253
329
  }
254
330
 
255
- function convertMdastNode(node: MdastNode): JxElement | null {
331
+ /**
332
+ * Convert mdast children, dropping the ones with no Jx form. A hand-built tree can carry holes
333
+ * (`null`) among them, and those are dropped too.
334
+ */
335
+ function convertMdastChildren(nodes: readonly (MdastNode | null)[]): (JxElement | string)[] {
336
+ return nodes.flatMap((n) => {
337
+ const converted = convertMdastNode(n);
338
+ return converted ? [converted] : [];
339
+ });
340
+ }
341
+
342
+ function convertMdastNode(node: MdastNode | null): JxElement | string | null {
256
343
  if (!node) {
257
344
  return null;
258
345
  }
@@ -270,10 +357,11 @@ function convertMdastNode(node: MdastNode): JxElement | null {
270
357
  return null;
271
358
  }
272
359
  const nodes = htmlToJx(node.value);
273
- return nodes.length === 1 ? (nodes[0] as JxElement) : { children: nodes, tagName: "div" };
360
+ const [only] = nodes;
361
+ return nodes.length === 1 && only !== undefined ? only : { children: nodes, tagName: "div" };
274
362
  }
275
363
 
276
- const tagFn = MDAST_TAG_MAP[node.type];
364
+ const tagFn = hasJxTagName(node.type) ? MDAST_TAG_MAP[node.type] : undefined;
277
365
  if (!tagFn) {
278
366
  return null;
279
367
  }
@@ -281,11 +369,7 @@ function convertMdastNode(node: MdastNode): JxElement | null {
281
369
  const tag = tagFn(node);
282
370
  const el: JxElement = { tagName: tag };
283
371
 
284
- const childNodes = () =>
285
- (node.children ?? []).flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
286
- | JxElement
287
- | string
288
- )[];
372
+ const childNodes = () => convertMdastChildren(node.children ?? []);
289
373
  const flattenOrChildren = () => {
290
374
  if (node.children?.length === 1 && node.children[0]!.type === "text") {
291
375
  el.textContent = node.children[0]!.value ?? null;
@@ -348,13 +432,11 @@ function convertMdastNode(node: MdastNode): JxElement | null {
348
432
  }
349
433
 
350
434
  case "code": {
351
- el.children = [
352
- {
353
- tagName: "code",
354
- textContent: node.value ?? null,
355
- ...(node.lang ? { className: `language-${node.lang}` } : {}),
356
- },
357
- ];
435
+ const code: JxElement = { tagName: "code", textContent: node.value ?? null };
436
+ if (node.lang) {
437
+ code.className = `language-${node.lang}`;
438
+ }
439
+ el.children = [code];
358
440
  break;
359
441
  }
360
442
 
@@ -364,19 +446,15 @@ function convertMdastNode(node: MdastNode): JxElement | null {
364
446
  }
365
447
 
366
448
  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)[];
449
+ const [headRow, ...bodyRows] = childNodes();
450
+ const sections: JxElement[] = [];
451
+ if (headRow !== undefined) {
452
+ sections.push({ children: [headRow], tagName: "thead" });
453
+ }
454
+ if (bodyRows.length > 0) {
455
+ sections.push({ children: bodyRows, tagName: "tbody" });
456
+ }
457
+ el.children = sections;
380
458
  break;
381
459
  }
382
460
  default: {
@@ -390,7 +468,7 @@ function convertMdastNode(node: MdastNode): JxElement | null {
390
468
  function convertDirective(node: MdastNode): JxElement {
391
469
  // Prototype directive (e.g. `:::Array`) → `{ $prototype: name, ... }`, tagName dropped.
392
470
  if (node.name && PROTOTYPE_DIRECTIVE_NAMES.has(node.name)) {
393
- return prototypeDirectiveToJx(node);
471
+ return prototypeDirectiveToJx(node, node.name);
394
472
  }
395
473
 
396
474
  const el: JxElement = { tagName: node.name ?? "div" };
@@ -401,16 +479,10 @@ function convertDirective(node: MdastNode): JxElement {
401
479
  if (node.children?.length === 1 && node.children[0]!.type === "text") {
402
480
  el.textContent = node.children[0]!.value ?? null;
403
481
  } else if (node.children?.length) {
404
- el.children = node.children.flatMap((n) => convertMdastNode(n)).filter(Boolean) as (
405
- | JxElement
406
- | string
407
- )[];
482
+ el.children = convertMdastChildren(node.children);
408
483
  }
409
484
  } 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
- )[];
485
+ el.children = convertMdastChildren(node.children);
414
486
  }
415
487
  return el;
416
488
  }
@@ -419,25 +491,23 @@ function convertDirective(node: MdastNode): JxElement {
419
491
  * Roundtrip md → Jx for a prototype directive (e.g. `:::Array`): name → `$prototype` (tagName
420
492
  * dropped), dot-path attributes expanded (items/filter/sort), single nested child → `map`.
421
493
  */
422
- function prototypeDirectiveToJx(node: MdastNode): JxElement {
423
- const el: JxElement = { $prototype: node.name as string };
494
+ function prototypeDirectiveToJx(node: MdastNode, name: string): JxElement {
495
+ const el: JxElement = { $prototype: name };
424
496
  if (node.attributes && Object.keys(node.attributes).length > 0) {
425
497
  const expanded = expandDotPaths(node.attributes);
426
498
  for (const [key, value] of Object.entries(expanded)) {
427
499
  if (key === "$prototype") {
428
500
  continue;
429
501
  }
502
+ /* SAFETY: expandDotPaths nests the directive's attribute strings under their dot-path
503
+ segments, so every value it returns is a string or an object of those: JSON. */
430
504
  el[key] = value as JsonValue;
431
505
  }
432
506
  }
433
507
  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");
508
+ const template = convertMdastChildren(node.children).find((child) => isNodeObject(child));
439
509
  if (template) {
440
- el.map = template as JxElement;
510
+ el.map = template;
441
511
  }
442
512
  }
443
513
  return el;
@@ -449,14 +519,37 @@ function prototypeDirectiveToJx(node: MdastNode): JxElement {
449
519
  * Convert a Jx element tree to an mdast tree (lossless: non-markdown elements become directives).
450
520
  * Inverse of {@link mdastToJx}.
451
521
  */
452
- export function jxToMdast(jx: JxElement, opts: SerializeOptions = {}): MdastNode {
522
+ export function jxToMdast(
523
+ jx: JxElement,
524
+ opts: Pick<SerializeOptions, "allowlist"> = {},
525
+ ): MdastRoot {
453
526
  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[];
527
+ return { children: convertJxChildren(childList(jx), true, allowlist), type: "root" };
528
+ }
529
+
530
+ /** Convert Jx children to mdast, dropping the ones with no mdast form. */
531
+ function convertJxChildren(
532
+ children: readonly ChildSlot[],
533
+ isBlock: boolean,
534
+ allowlist: ReadonlySet<string>,
535
+ ): MdastNode[] {
536
+ return children.flatMap((child) => {
537
+ const converted = convertJxNode(child, isBlock, allowlist);
538
+ return converted ? [converted] : [];
539
+ });
540
+ }
458
541
 
459
- return { children, type: "root" };
542
+ /**
543
+ * An attribute as mdast text: a string as written, any other truthy value stringified (which is how
544
+ * remark-stringify prints it), and nothing else as empty.
545
+ */
546
+ function attributeText(value: JxAttributeValue | undefined): string {
547
+ return isString(value) ? value : value ? String(value) : "";
548
+ }
549
+
550
+ /** An attribute as an mdast `title`: like {@link attributeText}, but absent rather than empty. */
551
+ function attributeTitle(value: JxAttributeValue | undefined): string | null {
552
+ return isString(value) ? value : value ? String(value) : null;
460
553
  }
461
554
 
462
555
  function normalizeAllowlist(allowlist?: ReadonlySet<string> | string[]): ReadonlySet<string> {
@@ -483,26 +576,148 @@ function hasJxProps(el: JxElement) {
483
576
  return false;
484
577
  }
485
578
 
579
+ /**
580
+ * The attributes each markdown-native tag can state in markdown syntax: a link's `href` and
581
+ * `title`, an image's `src`, `alt` and `title`, an ordered list's `start`. Any other attribute (an
582
+ * image's `width` and `height`, a heading's `id`) has no markdown spelling, so an element that
583
+ * carries one is written as a directive, which keeps every attribute, rather than as markdown that
584
+ * would drop it.
585
+ */
586
+ const MD_ATTRIBUTES = new Map([
587
+ ["a", new Set(["href", "title"])],
588
+ ["img", new Set(["alt", "src", "title"])],
589
+ ["ol", new Set(["start"])],
590
+ ]);
591
+
592
+ /** Whether every attribute an element carries has a markdown spelling for its tag. */
593
+ function hasOnlyMarkdownAttributes(el: JxElement, tag: string): boolean {
594
+ const allowed = MD_ATTRIBUTES.get(tag);
595
+ return Object.keys(el.attributes ?? {}).every((name) => allowed?.has(name) ?? false);
596
+ }
597
+
598
+ /**
599
+ * The parts of a table. They are markdown only inside a table GFM can write ({@link isGfmTable}),
600
+ * which converts them itself; met anywhere else (a directive table's `tbody`, a stray `tr`) they
601
+ * are directives, since they have no markdown form of their own.
602
+ */
603
+ const TABLE_PARTS: ReadonlySet<string> = new Set(["tbody", "td", "th", "thead", "tr"]);
604
+
605
+ /** A table part's children, without the whitespace an HTML source left between its tags. */
606
+ function partChildren(el: JxElement): (JxElement | string)[] {
607
+ return childList(el).filter((child) => !isString(child) || child.trim() !== "");
608
+ }
609
+
610
+ /** `slot` as a plain element of `tag`: no attributes and no Jx props, or `null`. */
611
+ function plainPart(slot: JxElement | string | undefined, tag: string): JxElement | null {
612
+ if (slot === undefined || isString(slot) || slot.tagName !== tag) {
613
+ return null;
614
+ }
615
+ const attributes = Object.keys(slot.attributes ?? {});
616
+ return attributes.length === 0 && !hasJxProps(slot) ? slot : null;
617
+ }
618
+
619
+ /** Whether a table cell holds only what a GFM cell can: one line of inline content. */
620
+ function isGfmCell(cell: JxElement): boolean {
621
+ if (cell.textContent != null) {
622
+ return !String(cell.textContent).includes("\n");
623
+ }
624
+ return childList(cell).every((child) =>
625
+ isString(child)
626
+ ? !child.includes("\n")
627
+ : isString(child.tagName) && (MD_INLINE.has(child.tagName) || child.tagName === "span"),
628
+ );
629
+ }
630
+
631
+ /** A row's cells when every one is a plain `cellTag` holding GFM cell content, else `null`. */
632
+ function gfmRowCells(row: JxElement | string | undefined, cellTag: string): JxElement[] | null {
633
+ const tr = plainPart(row, "tr");
634
+ if (!tr || tr.textContent != null) {
635
+ return null;
636
+ }
637
+ const cells: JxElement[] = [];
638
+ for (const slot of partChildren(tr)) {
639
+ const cell = plainPart(slot, cellTag);
640
+ if (!cell || !isGfmCell(cell)) {
641
+ return null;
642
+ }
643
+ cells.push(cell);
644
+ }
645
+ return cells.length > 0 ? cells : null;
646
+ }
647
+
648
+ /**
649
+ * The rows of a table GFM can write and read back as the same element tree, or `null`: a `thead`
650
+ * holding one row of `th` cells, then optionally a `tbody` whose every row has as many `td` cells,
651
+ * none of them carrying an attribute or anything but one line of inline content. That is exactly
652
+ * the shape the parser makes of a pipe table. Any other table (no header row, `td` cells in the
653
+ * head, a caption, a class) would come back as a different tree, so it stays a directive.
654
+ */
655
+ function gfmTableRows(el: JxElement): { head: JxElement[]; body: JxElement[][] } | null {
656
+ if (el.textContent != null) {
657
+ return null;
658
+ }
659
+ const [headSlot, bodySlot, ...rest] = partChildren(el);
660
+ const thead = plainPart(headSlot, "thead");
661
+ if (!thead || rest.length > 0) {
662
+ return null;
663
+ }
664
+ const headRows = partChildren(thead);
665
+ const head = headRows.length === 1 ? gfmRowCells(headRows[0], "th") : null;
666
+ if (!head) {
667
+ return null;
668
+ }
669
+ if (bodySlot === undefined) {
670
+ return { body: [], head };
671
+ }
672
+ const tbody = plainPart(bodySlot, "tbody");
673
+ const rows = tbody ? partChildren(tbody) : [];
674
+ const body: JxElement[][] = [];
675
+ for (const row of rows) {
676
+ const cells = gfmRowCells(row, "td");
677
+ if (!cells || cells.length !== head.length) {
678
+ return null;
679
+ }
680
+ body.push(cells);
681
+ }
682
+ return body.length > 0 ? { body, head } : null;
683
+ }
684
+
486
685
  function convertJxNode(
487
- el: JxElement | string | number,
686
+ slot: JxDocumentValue | undefined,
488
687
  isBlock: boolean,
489
688
  allowlist: ReadonlySet<string>,
490
689
  ): MdastNode | null {
491
- if (typeof el === "string" || typeof el === "number") {
492
- return { type: "text", value: String(el) };
690
+ if (isString(slot) || isNumber(slot)) {
691
+ return { type: "text", value: String(slot) };
493
692
  }
494
- if (!el || typeof el !== "object") {
693
+ if (!isNonNullObject(slot)) {
495
694
  return null;
496
695
  }
696
+ /* SAFETY: `slot` is a non-null object. A child slot holds an element by contract; any other
697
+ object a hand-edited document left there (an array, a binding) is read below only through
698
+ JxElement's optional members and Object.entries, which every object answers, so it converts
699
+ the way it always has. */
700
+ const el = slot as JxElement;
497
701
 
498
702
  const tag = serializableTag(el.tagName) || "div";
499
703
 
500
- // Not markdown-native or carries Jx-specific props → directive
501
- if (!allowlist.has(tag) || hasJxProps(el)) {
704
+ /* Not markdown-native, carries Jx-specific props or an attribute markdown cannot spell, or is a
705
+ table part outside a GFM table → directive. So is an image standing at block level: markdown
706
+ has only the inline `![alt](src)`, which would run into the paragraph beside it, and the
707
+ `::img{…}` leaf it was read from is its exact form. */
708
+ const tableRows = tag === "table" ? gfmTableRows(el) : null;
709
+ if (
710
+ !allowlist.has(tag) ||
711
+ hasJxProps(el) ||
712
+ !hasOnlyMarkdownAttributes(el, tag) ||
713
+ TABLE_PARTS.has(tag) ||
714
+ (tag === "table" && tableRows === null) ||
715
+ (isBlock && tag === "img")
716
+ ) {
502
717
  return convertToDirective(el, isBlock, allowlist);
503
718
  }
504
719
 
505
- const mdastType = TAG_MDAST_MAP[tag];
720
+ const mdastType = hasMdastType(tag) ? TAG_MDAST_MAP[tag] : undefined;
506
721
  if (!mdastType) {
507
722
  return null;
508
723
  }
@@ -511,9 +726,7 @@ function convertJxNode(
511
726
  if (e.textContent != null) {
512
727
  return [{ type: "text", value: String(e.textContent) }];
513
728
  }
514
- return ((e.children ?? []) as (JxElement | string)[])
515
- .map((c) => convertJxNode(c, false, allowlist))
516
- .filter(Boolean) as MdastNode[];
729
+ return convertJxChildren(childList(e), false, allowlist);
517
730
  };
518
731
  const block = (e: JxElement): MdastNode[] => {
519
732
  if (e.textContent != null) {
@@ -524,9 +737,7 @@ function convertJxNode(
524
737
  },
525
738
  ];
526
739
  }
527
- return ((e.children ?? []) as (JxElement | string)[])
528
- .map((c) => convertJxNode(c, true, allowlist))
529
- .filter(Boolean) as MdastNode[];
740
+ return convertJxChildren(childList(e), true, allowlist);
530
741
  };
531
742
 
532
743
  switch (mdastType) {
@@ -559,18 +770,18 @@ function convertJxNode(
559
770
  case "link": {
560
771
  return {
561
772
  children: inline(el),
562
- title: (el.attributes?.title as string | null) ?? null,
773
+ title: attributeTitle(el.attributes?.title),
563
774
  type: "link",
564
- url: (el.attributes?.href as string) ?? "",
775
+ url: attributeText(el.attributes?.href),
565
776
  };
566
777
  }
567
778
 
568
779
  case "image": {
569
780
  return {
570
- alt: (el.attributes?.alt as string) ?? "",
571
- title: (el.attributes?.title as string | null) ?? null,
781
+ alt: attributeText(el.attributes?.alt),
782
+ title: attributeTitle(el.attributes?.title),
572
783
  type: "image",
573
- url: (el.attributes?.src as string) ?? "",
784
+ url: attributeText(el.attributes?.src),
574
785
  };
575
786
  }
576
787
 
@@ -580,12 +791,10 @@ function convertJxNode(
580
791
 
581
792
  case "list": {
582
793
  return {
583
- children: ((el.children ?? []) as (JxElement | string)[])
584
- .map((c) => convertJxNode(c, true, allowlist))
585
- .filter(Boolean) as MdastNode[],
794
+ children: convertJxChildren(childList(el), true, allowlist),
586
795
  ordered: tag === "ol",
587
796
  spread: false,
588
- start: tag === "ol" ? Math.trunc(Number(el.attributes?.start as string)) || 1 : null,
797
+ start: tag === "ol" ? Math.trunc(Number(el.attributes?.start)) || 1 : null,
589
798
  type: "list",
590
799
  };
591
800
  }
@@ -595,9 +804,8 @@ function convertJxNode(
595
804
  }
596
805
 
597
806
  case "code": {
598
- const codeChild = Array.isArray(el.children)
599
- ? (el.children[0] as JxElement | undefined)
600
- : undefined;
807
+ const [first] = childList(el);
808
+ const codeChild = isString(first) ? undefined : first;
601
809
  return {
602
810
  lang: codeLang(codeChild),
603
811
  type: "code",
@@ -614,41 +822,21 @@ function convertJxNode(
614
822
  }
615
823
 
616
824
  case "table": {
617
- // Flatten thead/tbody back to rows
618
- const rows: MdastNode[] = [];
619
- for (const section of (el.children ?? []) as (JxElement | string)[]) {
620
- if (typeof section === "string") {
621
- continue;
622
- }
623
- if (section.tagName === "thead" || section.tagName === "tbody") {
624
- for (const row of (section.children ?? []) as (JxElement | string)[]) {
625
- const mdRow = convertJxNode(row, true, allowlist);
626
- if (mdRow) {
627
- if (section.tagName === "thead") {
628
- for (const cell of mdRow.children ?? []) {
629
- cell.isHeader = true;
630
- }
631
- }
632
- rows.push(mdRow);
633
- }
634
- }
635
- }
636
- }
637
- return { children: rows, type: "table" };
638
- }
639
-
640
- case "tableRow": {
641
- return {
642
- children: ((el.children ?? []) as (JxElement | string)[])
643
- .map((c) => convertJxNode(c, false, allowlist))
644
- .filter(Boolean) as MdastNode[],
825
+ // Flatten thead/tbody back to rows: the header row first, as GFM writes it.
826
+ const { body, head } = tableRows ?? { body: [], head: [] };
827
+ const row = (cells: readonly JxElement[], isHeader: boolean): MdastNode => ({
828
+ children: cells.map((cell) => ({
829
+ children: inline(cell),
830
+ type: "tableCell",
831
+ ...(isHeader && { isHeader: true }),
832
+ })),
645
833
  type: "tableRow",
834
+ });
835
+ return {
836
+ children: [row(head, true), ...body.map((cells) => row(cells, false))],
837
+ type: "table",
646
838
  };
647
839
  }
648
-
649
- case "tableCell": {
650
- return { children: inline(el), type: "tableCell" };
651
- }
652
840
  default: {
653
841
  break;
654
842
  }
@@ -700,11 +888,11 @@ const JX_DOLLAR_KEYS = new Set([
700
888
  * chosen tags today — the construct is an element-document (component) feature — so this refuses
701
889
  * rather than guesses, and says which candidates it saw.
702
890
  *
703
- * @param {unknown} tagName
891
+ * @param {TagNameSlot} tagName
704
892
  * @returns {string} The literal tag, or "" when none is declared
705
893
  */
706
- function serializableTag(tagName: unknown): string {
707
- if (typeof tagName === "string") {
894
+ function serializableTag(tagName: TagNameSlot): string {
895
+ if (isString(tagName)) {
708
896
  return tagName;
709
897
  }
710
898
  if (tagName == null) {
@@ -724,15 +912,18 @@ const PROTOTYPE_DIRECTIVE_NAMES = new Set(["Array"]);
724
912
 
725
913
  const JX_ANNOTATION_KEYS = new Set(["$title", "$description"]);
726
914
 
915
+ /** Element props (and attributes) bound for directive attributes, keyed as the element keys them. */
916
+ type DirectiveProps = Record<string, JxDocumentValue | undefined>;
917
+
727
918
  function collectDirectiveAttrs(el: JxElement) {
728
- const propsObj: Record<string, unknown> = {};
919
+ const propsObj: DirectiveProps = {};
729
920
 
730
921
  for (const [key, value] of Object.entries(el)) {
731
922
  if (key === "tagName" || key === "textContent" || key === "innerHTML" || key === "attributes") {
732
923
  continue;
733
924
  }
734
925
  if (key === "children") {
735
- if (value && typeof value === "object" && !Array.isArray(value)) {
926
+ if (isJsonObject(value)) {
736
927
  propsObj.children = value;
737
928
  }
738
929
  continue;
@@ -741,7 +932,7 @@ function collectDirectiveAttrs(el: JxElement) {
741
932
  }
742
933
 
743
934
  if (el.attributes) {
744
- for (const [key, value] of Object.entries(el.attributes as Record<string, unknown>)) {
935
+ for (const [key, value] of Object.entries(el.attributes)) {
745
936
  propsObj[key] = value;
746
937
  }
747
938
  }
@@ -756,15 +947,12 @@ function convertToDirective(
756
947
  ): MdastNode {
757
948
  // Prototype pseudo-element (e.g. Array repeater) with no tagName → directive named after the
758
949
  // 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);
950
+ if (!el.tagName && isString(el.$prototype) && PROTOTYPE_DIRECTIVE_NAMES.has(el.$prototype)) {
951
+ return prototypeToDirective(el, el.$prototype, isBlock, allowlist);
765
952
  }
766
953
 
767
- const tag = (el.tagName as string) ?? "div";
954
+ // The caller has already refused a chosen tag (serializableTag), so a tag here is a literal.
955
+ const tag = isString(el.tagName) ? el.tagName : "div";
768
956
  const attrs = collectDirectiveAttrs(el);
769
957
 
770
958
  if (!isBlock) {
@@ -773,20 +961,14 @@ function convertToDirective(
773
961
  children:
774
962
  el.textContent != null
775
963
  ? [{ type: "text", value: String(el.textContent) }]
776
- : (((el.children ?? []) as (JxElement | string)[])
777
- .map((c) => convertJxNode(c, false, allowlist))
778
- .filter(Boolean) as MdastNode[]),
964
+ : convertJxChildren(childList(el), false, allowlist),
779
965
  name: tag,
780
966
  type: "textDirective",
781
967
  };
782
968
  }
783
969
 
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);
970
+ // A legacy whole-slot repeater (an object) has no child list to emit.
971
+ const childArray = Array.isArray(el.children) ? el.children : undefined;
790
972
  if (!childArray?.length && el.textContent == null) {
791
973
  return {
792
974
  attributes: attrs,
@@ -806,15 +988,11 @@ function convertToDirective(
806
988
  ];
807
989
  } else if (INLINE_CONTENT_TAGS.has(tag)) {
808
990
  // 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[];
991
+ const inlineNodes = convertJxChildren(childList(el), false, allowlist);
812
992
  directiveChildren =
813
993
  inlineNodes.length > 0 ? [{ children: inlineNodes, type: "paragraph" }] : [];
814
994
  } else {
815
- directiveChildren = ((el.children ?? []) as (JxElement | string)[])
816
- .map((c) => convertJxNode(c, true, allowlist))
817
- .filter(Boolean) as MdastNode[];
995
+ directiveChildren = convertJxChildren(childList(el), true, allowlist);
818
996
  }
819
997
 
820
998
  return {
@@ -833,10 +1011,11 @@ function convertToDirective(
833
1011
  */
834
1012
  function prototypeToDirective(
835
1013
  el: JxElement,
1014
+ prototype: string,
836
1015
  isBlock: boolean,
837
1016
  allowlist: ReadonlySet<string>,
838
1017
  ): MdastNode {
839
- const propsObj: Record<string, unknown> = {};
1018
+ const propsObj: DirectiveProps = {};
840
1019
  for (const [key, value] of Object.entries(el)) {
841
1020
  if (key === "$prototype" || key === "map" || key === "tagName") {
842
1021
  continue;
@@ -844,20 +1023,20 @@ function prototypeToDirective(
844
1023
  propsObj[key] = value;
845
1024
  }
846
1025
  const attrs = collapsePropsToAttrMap(propsObj);
847
- const mapNode = el.map ? convertJxNode(el.map as JxElement, isBlock, allowlist) : null;
1026
+ const mapNode = el.map ? convertJxNode(el.map, isBlock, allowlist) : null;
848
1027
  const children = mapNode ? [mapNode] : [];
849
1028
  return {
850
1029
  attributes: attrs,
851
1030
  children,
852
- name: el.$prototype as string,
1031
+ name: prototype,
853
1032
  type: isBlock ? "containerDirective" : "textDirective",
854
1033
  };
855
1034
  }
856
1035
 
857
- function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
1036
+ function collapsePropsToAttrMap(propsObj: DirectiveProps) {
858
1037
  const result: Record<string, string> = {};
859
1038
 
860
- function walk(obj: Record<string, unknown>, prefix: string) {
1039
+ function walk(obj: DirectiveProps, prefix: string) {
861
1040
  for (const [key, value] of Object.entries(obj)) {
862
1041
  let mdAttrKey = key;
863
1042
  if (JX_DOLLAR_KEYS.has(key)) {
@@ -871,8 +1050,8 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
871
1050
  }
872
1051
  if (key === "@") {
873
1052
  // 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);
1053
+ if (isJsonObject(value)) {
1054
+ walk(value, prefix);
876
1055
  }
877
1056
  continue;
878
1057
  }
@@ -882,8 +1061,8 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
882
1061
 
883
1062
  const fullKey = prefix ? `${prefix}.${mdAttrKey}` : mdAttrKey;
884
1063
 
885
- if (value && typeof value === "object" && !Array.isArray(value)) {
886
- walk(value as Record<string, unknown>, fullKey);
1064
+ if (isJsonObject(value)) {
1065
+ walk(value, fullKey);
887
1066
  } else {
888
1067
  result[fullKey] = String(value);
889
1068
  }
@@ -898,24 +1077,29 @@ function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
898
1077
  // Export mode — clean markdown (lossy, no directives)
899
1078
  // ═════════════════════════════════════════════════════════════════════════════
900
1079
 
901
- interface ExportContext {
1080
+ interface ExportContext<ScopeValue> {
902
1081
  componentDefs: Map<string, JxElement>;
903
- evaluateTemplate?: SerializeOptions["evaluateTemplate"];
904
- buildScope?: SerializeOptions["buildScope"];
1082
+ evaluateTemplate?: SerializeOptions<ScopeValue>["evaluateTemplate"];
1083
+ buildScope?: SerializeOptions<ScopeValue>["buildScope"];
905
1084
  }
906
1085
 
907
- function nodeToMdast(
908
- node: JxElement | string,
909
- ctx: ExportContext,
910
- scope?: Record<string, unknown> | null,
1086
+ /**
1087
+ * Export one child slot: a string or number as text, an element object as markdown. Anything else a
1088
+ * hand-edited document or a repeater row left in the slot (null, a boolean, an array) exports
1089
+ * nothing; an array would have reached the empty wrapper branch anyway.
1090
+ */
1091
+ function nodeToMdast<ScopeValue>(
1092
+ node: JxDocumentValue | undefined,
1093
+ ctx: ExportContext<ScopeValue>,
1094
+ scope?: Record<string, ScopeValue> | null,
911
1095
  ): MdastNode[] {
912
- if (typeof node === "string") {
1096
+ if (isString(node)) {
913
1097
  return node.trim() ? [{ type: "text", value: node }] : [];
914
1098
  }
915
- if (typeof node === "number") {
1099
+ if (isNumber(node)) {
916
1100
  return [{ type: "text", value: String(node) }];
917
1101
  }
918
- if (!node || typeof node !== "object") {
1102
+ if (!isNodeObject(node)) {
919
1103
  return [];
920
1104
  }
921
1105
 
@@ -928,7 +1112,7 @@ function nodeToMdast(
928
1112
  const text = resolveText(node.textContent, ctx, scope);
929
1113
 
930
1114
  // InnerHTML — convert HTML content to mdast
931
- if (typeof node.innerHTML === "string" && node.innerHTML.trim()) {
1115
+ if (isString(node.innerHTML) && node.innerHTML.trim()) {
932
1116
  const htmlNodes = htmlToMdast(node.innerHTML);
933
1117
  if (htmlNodes.length > 0) {
934
1118
  return htmlNodes;
@@ -948,7 +1132,7 @@ function nodeToMdast(
948
1132
  return exportChildren(node, ctx, scope);
949
1133
  }
950
1134
 
951
- const mdastType = TAG_MDAST_MAP[tag];
1135
+ const mdastType = hasMdastType(tag) ? TAG_MDAST_MAP[tag] : undefined;
952
1136
  if (!mdastType) {
953
1137
  if (text != null) {
954
1138
  return text.trim() ? [{ children: [{ type: "text", value: text }], type: "paragraph" }] : [];
@@ -987,7 +1171,7 @@ function nodeToMdast(
987
1171
 
988
1172
  case "link": {
989
1173
  const href = String(node.attributes?.href ?? "");
990
- const title = (node.attributes?.title as string | null) ?? null;
1174
+ const title = attributeTitle(node.attributes?.title);
991
1175
  const children =
992
1176
  text != null ? [{ type: "text", value: text }] : exportChildren(node, ctx, scope);
993
1177
  return [{ children, title, type: "link", url: href }];
@@ -996,7 +1180,7 @@ function nodeToMdast(
996
1180
  case "image": {
997
1181
  const src = String(node.attributes?.src ?? "");
998
1182
  const alt = String(node.attributes?.alt ?? "");
999
- const title = (node.attributes?.title as string | null) ?? null;
1183
+ const title = attributeTitle(node.attributes?.title);
1000
1184
  return [{ alt, title, type: "image", url: src }];
1001
1185
  }
1002
1186
 
@@ -1027,13 +1211,13 @@ function nodeToMdast(
1027
1211
  }
1028
1212
 
1029
1213
  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 ?? "";
1214
+ const codeChild = childList(node).find(
1215
+ (c): c is JxElement => isNodeObject(c) && c.tagName === "code",
1216
+ );
1217
+ const value = textOf(codeChild) ?? text ?? "";
1034
1218
  return [
1035
1219
  {
1036
- lang: codeLang(codeChild as JxElement | undefined),
1220
+ lang: codeLang(codeChild),
1037
1221
  type: "code",
1038
1222
  value,
1039
1223
  },
@@ -1085,10 +1269,10 @@ function nodeToMdast(
1085
1269
  return [];
1086
1270
  }
1087
1271
 
1088
- function exportChildren(
1272
+ function exportChildren<ScopeValue>(
1089
1273
  node: JxElement,
1090
- ctx: ExportContext,
1091
- scope?: Record<string, unknown> | null,
1274
+ ctx: ExportContext<ScopeValue>,
1275
+ scope?: Record<string, ScopeValue> | null,
1092
1276
  ): MdastNode[] {
1093
1277
  if (node.textContent != null) {
1094
1278
  const text = resolveText(node.textContent, ctx, scope);
@@ -1105,7 +1289,11 @@ function exportChildren(
1105
1289
 
1106
1290
  // ─── Export: component inlining ──────────────────────────────────────────────
1107
1291
 
1108
- function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): MdastNode[] {
1292
+ function inlineComponent<ScopeValue>(
1293
+ node: JxElement,
1294
+ tag: string,
1295
+ ctx: ExportContext<ScopeValue>,
1296
+ ): MdastNode[] {
1109
1297
  const def = ctx.componentDefs.get(tag);
1110
1298
  if (!def) {
1111
1299
  return exportChildren(node, ctx);
@@ -1118,14 +1306,9 @@ function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): Mdas
1118
1306
  if (key in stateDefs) {
1119
1307
  const existing = stateDefs[key];
1120
1308
  stateDefs[key] =
1121
- existing &&
1122
- typeof existing === "object" &&
1123
- !Array.isArray(existing) &&
1124
- "default" in existing
1125
- ? { ...existing, default: value }
1126
- : (value as JxStateDefinition);
1309
+ isJsonObject(existing) && "default" in existing ? { ...existing, default: value } : value;
1127
1310
  } else {
1128
- stateDefs[key] = value as JxStateDefinition;
1311
+ stateDefs[key] = value;
1129
1312
  }
1130
1313
  }
1131
1314
 
@@ -1140,43 +1323,52 @@ function inlineComponent(node: JxElement, tag: string, ctx: ExportContext): Mdas
1140
1323
  const instanceChildren = node.children;
1141
1324
  return resolved.flatMap((child: JxElement | string): MdastNode[] => {
1142
1325
  // Replace slot elements with instance children
1143
- if (typeof child !== "string" && child?.tagName === "slot" && Array.isArray(instanceChildren)) {
1326
+ if (isNodeObject(child) && child.tagName === "slot" && Array.isArray(instanceChildren)) {
1144
1327
  return instanceChildren.flatMap((c: JxElement | string): MdastNode[] => nodeToMdast(c, ctx));
1145
1328
  }
1146
1329
  return nodeToMdast(child, ctx, scope);
1147
1330
  });
1148
1331
  }
1149
1332
 
1150
- function deepResolve(
1151
- nodes: (JxElement | string)[],
1152
- scope: Record<string, unknown>,
1153
- ctx: ExportContext,
1333
+ function deepResolve<ScopeValue>(
1334
+ nodes: readonly (JxElement | string)[],
1335
+ scope: Record<string, ScopeValue>,
1336
+ ctx: ExportContext<ScopeValue>,
1154
1337
  ) {
1155
1338
  return nodes.map((node) => resolveNode(node, scope, ctx));
1156
1339
  }
1157
1340
 
1158
- function resolveNode(
1341
+ /**
1342
+ * Evaluate the templates in one child slot. An element comes back as an evaluated copy and a string
1343
+ * as its evaluation; anything else a hand-edited document left in the slot comes back as it was.
1344
+ */
1345
+ function resolveNode<ScopeValue>(
1159
1346
  node: JxElement | string,
1160
- scope: Record<string, unknown>,
1161
- ctx: ExportContext,
1162
- ): JxElement | string {
1163
- if (typeof node === "string") {
1347
+ scope: Record<string, ScopeValue>,
1348
+ ctx: ExportContext<ScopeValue>,
1349
+ ): JxElement | string;
1350
+ function resolveNode<ScopeValue>(
1351
+ node: ChildSlot,
1352
+ scope: Record<string, ScopeValue>,
1353
+ ctx: ExportContext<ScopeValue>,
1354
+ ): ChildSlot {
1355
+ if (isString(node)) {
1164
1356
  const evaluated = ctx.evaluateTemplate?.(node, scope);
1165
1357
  return evaluated !== undefined ? String(evaluated) : node;
1166
1358
  }
1167
- if (!node || typeof node !== "object") {
1359
+ if (!isNonNullObject(node)) {
1168
1360
  return node;
1169
1361
  }
1170
1362
 
1171
1363
  const result = { ...node };
1172
1364
 
1173
- if (typeof result.textContent === "string") {
1365
+ if (isString(result.textContent)) {
1174
1366
  const evaluated = ctx.evaluateTemplate?.(result.textContent, scope);
1175
1367
  if (evaluated !== undefined) {
1176
1368
  result.textContent = String(evaluated);
1177
1369
  }
1178
1370
  }
1179
- if (typeof result.innerHTML === "string") {
1371
+ if (isString(result.innerHTML)) {
1180
1372
  const evaluated = ctx.evaluateTemplate?.(result.innerHTML, scope);
1181
1373
  if (evaluated !== undefined) {
1182
1374
  result.innerHTML = String(evaluated);
@@ -1185,9 +1377,9 @@ function resolveNode(
1185
1377
  if (result.attributes) {
1186
1378
  result.attributes = { ...result.attributes };
1187
1379
  for (const [k, v] of Object.entries(result.attributes)) {
1188
- if (typeof v === "string") {
1380
+ if (isString(v)) {
1189
1381
  // Template evaluation yields a substituted scalar for attribute values.
1190
- const evaluated = ctx.evaluateTemplate?.(v, scope) as JxAttributeValue | undefined;
1382
+ const evaluated = ctx.evaluateTemplate?.(v, scope);
1191
1383
  if (evaluated !== undefined) {
1192
1384
  result.attributes[k] = evaluated;
1193
1385
  }
@@ -1203,12 +1395,12 @@ function resolveNode(
1203
1395
 
1204
1396
  // ─── Export: array expansion ─────────────────────────────────────────────────
1205
1397
 
1206
- function expandArray(
1398
+ function expandArray<ScopeValue>(
1207
1399
  arrayDef: JxElement,
1208
- ctx: ExportContext,
1209
- scope?: Record<string, unknown> | null,
1400
+ ctx: ExportContext<ScopeValue>,
1401
+ scope?: Record<string, ScopeValue> | null,
1210
1402
  ): MdastNode[] {
1211
- const itemsValue = (arrayDef as JxMutableNode).items;
1403
+ const itemsValue = arrayDef.items;
1212
1404
  const itemsRef = isRef(itemsValue) ? itemsValue.$ref : undefined;
1213
1405
  if (!itemsRef || !scope) {
1214
1406
  return [];
@@ -1219,91 +1411,90 @@ function expandArray(
1219
1411
  return [];
1220
1412
  }
1221
1413
 
1222
- const mapTemplate = (arrayDef as JxMutableNode).map;
1414
+ const mapTemplate = arrayDef.map;
1223
1415
  if (!mapTemplate) {
1224
1416
  return [];
1225
1417
  }
1226
1418
 
1227
- return items.flatMap((item: JxMutableNode): MdastNode[] => {
1228
- const resolved = resolveMapNode(mapTemplate, item);
1229
- return nodeToMdast(resolved as JxElement | string, ctx, scope);
1419
+ return items.flatMap((row): MdastNode[] => {
1420
+ const resolved = isNodeObject(mapTemplate) ? resolveMapNode(mapTemplate, row) : mapTemplate;
1421
+ return nodeToMdast(resolved, ctx, scope);
1230
1422
  });
1231
1423
  }
1232
1424
 
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 };
1425
+ /**
1426
+ * A copy of a repeater template element with its `$map/` references replaced by what they name in
1427
+ * `row`. A child that is not an element object (a string, a number, an array, which exports nothing
1428
+ * either way) is kept as it is.
1429
+ */
1430
+ function resolveMapNode(node: JxElement, row: JsonValue): JxElement {
1431
+ const result: JxElement = { ...node };
1242
1432
 
1243
1433
  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
- }
1434
+ if (isRef(value) && value.$ref.startsWith("$map/")) {
1435
+ const path = value.$ref.slice("$map/".length);
1436
+ result[key] = resolvePath(row, path.startsWith("item/") ? path.slice("item/".length) : path);
1253
1437
  }
1254
1438
  }
1255
1439
 
1256
- if (result.$props) {
1257
- result.$props = resolveMapNode(
1258
- result.$props as unknown as JxMutableNode,
1259
- item,
1260
- ) as unknown as Record<string, JsonValue>;
1440
+ if (isNodeObject(result.$props)) {
1441
+ /* SAFETY: `$props` holds JSON, and resolveMapNode returns a copy that differs from it only where
1442
+ it substituted row data (JsonValue) or a nested value it resolved the same way, so the copy
1443
+ is still a JSON props object. */
1444
+ result.$props = resolveMapNode(result.$props, row) as Record<string, JsonValue>;
1261
1445
  }
1262
1446
 
1263
- if (typeof result.textContent === "string" && result.textContent.startsWith("$map/")) {
1264
- result.textContent = resolvePath(item, result.textContent.slice("$map/".length)) as unknown as
1447
+ if (isString(result.textContent) && result.textContent.startsWith("$map/")) {
1448
+ /* SAFETY: the row value can be any JSON (or undefined when the path misses), not just text.
1449
+ Every reader of a resolved template's textContent accepts that: resolveText treats null and
1450
+ undefined as no text and stringifies the rest, and textOf ignores non-strings. */
1451
+ result.textContent = resolvePath(row, result.textContent.slice("$map/".length)) as
1265
1452
  | string
1266
1453
  | null;
1267
1454
  }
1268
1455
 
1269
1456
  if (Array.isArray(result.children)) {
1270
- result.children = result.children.map((c) =>
1271
- resolveMapNode(c as unknown as JxMutableNode, item),
1272
- );
1457
+ result.children = result.children.map((c) => (isNodeObject(c) ? resolveMapNode(c, row) : c));
1273
1458
  }
1274
1459
 
1275
1460
  return result;
1276
1461
  }
1277
1462
 
1278
- function resolvePath(obj: unknown, path: string) {
1463
+ /** Walk `path` (steps split on `/` or `.`) from `root`; `undefined` once a step meets nothing. */
1464
+ function resolvePath(root: JsonValue | undefined, path: string): JsonValue | undefined {
1279
1465
  const parts = path.split(/[/.]/);
1280
- let current = obj as JxMutableNode;
1466
+ let current = root;
1281
1467
  for (const part of parts) {
1282
1468
  if (current == null) {
1283
1469
  return;
1284
1470
  }
1285
- // Paths address nodes by construction; non-node leaves surface as undefined above.
1286
- current = current[part] as JxMutableNode;
1471
+ /* SAFETY: `current` is neither null nor undefined, and every other value answers a member read
1472
+ by name (a string's `length`, an array's index). A member of JSON data is JSON, which is all a
1473
+ state or `$map/` path is written to reach; a step naming a method reads the function, as it
1474
+ always has. */
1475
+ current = (current as Readonly<Partial<Record<string, JsonValue>>>)[part];
1287
1476
  }
1288
1477
  return current;
1289
1478
  }
1290
1479
 
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);
1480
+ function resolveRef<ScopeValue>(ref: string, scope: Record<string, ScopeValue>) {
1481
+ const path = ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref;
1482
+ /* SAFETY: a repeater's `items` reference names a state entry, and the host's buildScope keeps
1483
+ each entry under its name. State is declared in the JSON document, so the entry a repeater
1484
+ names holds JSON; a host value that is not (a callable) is only read by name, like any object,
1485
+ and then fails the Array.isArray check before anything uses it. */
1486
+ return resolvePath(scope as JsonObject, path);
1296
1487
  }
1297
1488
 
1298
- function resolveText(
1299
- value: unknown,
1300
- ctx: ExportContext,
1301
- scope?: Record<string, unknown> | null,
1489
+ function resolveText<ScopeValue>(
1490
+ value: JxDocumentValue | undefined,
1491
+ ctx: ExportContext<ScopeValue>,
1492
+ scope?: Record<string, ScopeValue> | null,
1302
1493
  ): string | null {
1303
1494
  if (value == null) {
1304
1495
  return null;
1305
1496
  }
1306
- if (typeof value === "string") {
1497
+ if (isString(value)) {
1307
1498
  if (scope) {
1308
1499
  const evaluated = ctx.evaluateTemplate?.(value, scope);
1309
1500
  if (evaluated !== undefined) {
@@ -1635,7 +1826,10 @@ function decodeHtmlEntities(str: string) {
1635
1826
  * - `mode: "export"` — clean GFM: Jx decoration stripped, wrappers unwrapped, custom elements inlined
1636
1827
  * via `componentDefs`, templates evaluated via injected hooks.
1637
1828
  */
1638
- export function serializeJxMarkdown(doc: JxDocument, opts: SerializeOptions = {}): string {
1829
+ export function serializeJxMarkdown<ScopeValue>(
1830
+ doc: JxDocument,
1831
+ opts: SerializeOptions<ScopeValue> = {},
1832
+ ): string {
1639
1833
  const mode = opts.mode ?? "roundtrip";
1640
1834
 
1641
1835
  if (mode === "export") {
@@ -1644,11 +1838,32 @@ export function serializeJxMarkdown(doc: JxDocument, opts: SerializeOptions = {}
1644
1838
  return serializeRoundtrip(doc, opts);
1645
1839
  }
1646
1840
 
1647
- function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
1841
+ /* SAFETY: remark-stringify default-exports a unified attacher taking optional JSON settings (the
1842
+ bullet/emphasis/strong/setext options passed below), which is the UnifiedPlugin shape. Its
1843
+ declared type is unified's `Plugin`, and the root types.d.ts shadows "unified" with a shorthand
1844
+ `declare module`, so the type-aware linter sees this import as an error type, not that Plugin. */
1845
+ const remarkStringifyPlugin = remarkStringify as UnifiedPlugin;
1846
+
1847
+ /* SAFETY: remark-parse is a shorthand `declare module` in the root types.d.ts (its default import is
1848
+ `any`); it default-exports a unified attacher taking no settings, the UnifiedPlugin shape. */
1849
+ const remarkParsePlugin = remarkParse as UnifiedPlugin;
1850
+
1851
+ /** A fresh unified processor, seen through the surface this module drives. */
1852
+ function createProcessor(): UnifiedProcessor {
1853
+ /* SAFETY: unified() returns a Processor, which has the use/parse/runSync/stringify members
1854
+ UnifiedProcessor declares; that interface exists only because oxlint cannot resolve unified's
1855
+ own ESM types (see types.ts). */
1856
+ return (unified as () => UnifiedProcessor)();
1857
+ }
1858
+
1859
+ function serializeRoundtrip<ScopeValue>(
1860
+ doc: JxDocument,
1861
+ opts: SerializeOptions<ScopeValue>,
1862
+ ): string {
1648
1863
  const lines: string[] = [];
1649
1864
 
1650
1865
  if (opts.frontmatter !== false) {
1651
- const frontmatter: Record<string, unknown> = {};
1866
+ const frontmatter: DirectiveProps = {};
1652
1867
  for (const [key, value] of Object.entries(doc)) {
1653
1868
  if (key === "children") {
1654
1869
  continue;
@@ -1657,33 +1872,65 @@ function serializeRoundtrip(doc: JxDocument, opts: SerializeOptions): string {
1657
1872
  }
1658
1873
 
1659
1874
  if (Object.keys(frontmatter).length > 0) {
1660
- lines.push("---", stringifyYaml(frontmatter).trim(), "---", "");
1875
+ const sourceYaml = opts.source === undefined ? null : frontmatterText(opts.source);
1876
+ const spliced =
1877
+ sourceYaml === null
1878
+ ? null
1879
+ : spliceFrontmatter(frontmatter, sourceYaml, (values) => stringifyFrontmatter(values));
1880
+ lines.push("---", spliced ?? stringifyFrontmatter(frontmatter), "---", "");
1661
1881
  }
1662
1882
  }
1663
1883
 
1664
1884
  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)()
1885
+ const mdast = jxToMdast(doc, opts);
1886
+ const markers =
1887
+ opts.source === undefined
1888
+ ? { bullet: "-", emphasis: "*", strong: "*" }
1889
+ : sourceMarkers(opts.source);
1890
+ const processor = createProcessor()
1667
1891
  .use(remarkGfm)
1668
1892
  .use(remarkDirective)
1669
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" });
1670
- const md = processor.stringify(mdast as unknown as Root);
1671
-
1672
- lines.push(md);
1893
+ .use(remarkStringifyPlugin, { ...markers });
1894
+ // Runs of blank lines are squeezed in the body only: inside the frontmatter they are a block
1895
+ // Scalar's own text (a `|-` excerpt's paragraph breaks), and squeezing them edits a value.
1896
+ lines.push(processor.stringify(mdast).replaceAll(/\n{3,}/g, "\n\n"));
1673
1897
  }
1674
1898
 
1675
- return `${lines
1676
- .join("\n")
1677
- .replaceAll(/\n{3,}/g, "\n\n")
1678
- .trim()}\n`;
1899
+ return `${lines.join("\n").trim()}\n`;
1900
+ }
1901
+
1902
+ /**
1903
+ * The frontmatter block's YAML, written so that every reader gets back the values it was given.
1904
+ *
1905
+ * - `lineWidth: 0`: a long string stays on its line. Folding it (`>`) is a style the author did not
1906
+ * choose, and a one-word edit that refolds every paragraph of an excerpt is a diff nobody can
1907
+ * review.
1908
+ * - YAML 1.1 quoting: a string that a YAML 1.1 reader would type (`"2024-07-26T17:39:36Z"`, a
1909
+ * timestamp; `"yes"`, a boolean) stays quoted. The 1.2 core schema this package reads with calls
1910
+ * them strings unquoted, but the loaders a site's other tools use (js-yaml, gray-matter) do not,
1911
+ * and a date that was a string must not become a `Date` because Studio saved the file.
1912
+ */
1913
+ /** The markers the body of `source` was written with (`source-style.ts`). */
1914
+ function sourceMarkers(source: string): MarkdownMarkers {
1915
+ const body = bodyText(source);
1916
+ const tree = createProcessor()
1917
+ .use(remarkParsePlugin)
1918
+ .use(remarkGfm)
1919
+ .use(remarkDirective)
1920
+ .parse(body);
1921
+ return markdownMarkers(tree, body);
1679
1922
  }
1680
1923
 
1681
- function serializeExport(doc: JxDocument, opts: SerializeOptions): string {
1924
+ function stringifyFrontmatter(frontmatter: DirectiveProps): string {
1925
+ return stringify(frontmatter, { lineWidth: 0, version: "1.1" }).trim();
1926
+ }
1927
+
1928
+ function serializeExport<ScopeValue>(doc: JxDocument, opts: SerializeOptions<ScopeValue>): string {
1682
1929
  if (!Array.isArray(doc.children) || doc.children.length === 0) {
1683
1930
  return "";
1684
1931
  }
1685
1932
 
1686
- const ctx: ExportContext = {
1933
+ const ctx: ExportContext<ScopeValue> = {
1687
1934
  buildScope: opts.buildScope,
1688
1935
  componentDefs: opts.componentDefs ?? new Map<string, JxElement>(),
1689
1936
  evaluateTemplate: opts.evaluateTemplate,
@@ -1717,19 +1964,17 @@ function serializeExport(doc: JxDocument, opts: SerializeOptions): string {
1717
1964
  }
1718
1965
  flushInline();
1719
1966
 
1720
- const mdast = {
1967
+ const mdast: MdastNode = {
1721
1968
  children: cleaned,
1722
1969
  type: "root",
1723
- } as unknown as Root;
1970
+ };
1724
1971
 
1725
- const processor = (unified as unknown as () => UnifiedProcessor)()
1726
- .use(remarkGfm)
1727
- .use(remarkStringify, {
1728
- bullet: "-",
1729
- emphasis: "*",
1730
- setext: false,
1731
- strong: "*",
1732
- });
1972
+ const processor = createProcessor().use(remarkGfm).use(remarkStringifyPlugin, {
1973
+ bullet: "-",
1974
+ emphasis: "*",
1975
+ setext: false,
1976
+ strong: "*",
1977
+ });
1733
1978
  const md = processor.stringify(mdast);
1734
1979
 
1735
1980
  return `${md.replaceAll(/\n{3,}/g, "\n\n").trim()}\n`;