@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/dist/content-loader.js +608 -284
- package/dist/content.js +183 -30
- package/dist/markdown.js +394 -352
- package/dist/serialize.js +262 -232
- package/package.json +5 -5
- package/src/alerts.ts +22 -7
- package/src/content-data.ts +40 -0
- package/src/content-links.ts +15 -7
- package/src/content-loader.ts +681 -221
- package/src/content-routes.ts +23 -10
- package/src/content-rules.ts +40 -29
- package/src/content.ts +108 -70
- package/src/csv.ts +18 -14
- package/src/dates.ts +18 -13
- package/src/highlight.ts +6 -5
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md.ts +133 -64
- package/src/serialize.ts +367 -266
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - `export` — lossy: strips all Jx decoration, unwraps wrapper tags, inlines custom
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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 &&
|
|
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
|
-
}
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
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: (
|
|
247
|
-
.filter((n) => n.type !== "yaml" && n.type !== "toml")
|
|
248
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
513
|
+
export function jxToMdast(
|
|
514
|
+
jx: JxElement,
|
|
515
|
+
opts: Pick<SerializeOptions, "allowlist"> = {},
|
|
516
|
+
): MdastRoot {
|
|
453
517
|
const allowlist = normalizeAllowlist(opts.allowlist);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
571
|
+
slot: JxDocumentValue | undefined,
|
|
488
572
|
isBlock: boolean,
|
|
489
573
|
allowlist: ReadonlySet<string>,
|
|
490
574
|
): MdastNode | null {
|
|
491
|
-
if (
|
|
492
|
-
return { type: "text", value: String(
|
|
575
|
+
if (isString(slot) || isNumber(slot)) {
|
|
576
|
+
return { type: "text", value: String(slot) };
|
|
493
577
|
}
|
|
494
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
647
|
+
title: attributeTitle(el.attributes?.title),
|
|
563
648
|
type: "link",
|
|
564
|
-
url: (el.attributes?.href
|
|
649
|
+
url: attributeText(el.attributes?.href),
|
|
565
650
|
};
|
|
566
651
|
}
|
|
567
652
|
|
|
568
653
|
case "image": {
|
|
569
654
|
return {
|
|
570
|
-
alt: (el.attributes?.alt
|
|
571
|
-
title: (el.attributes?.title
|
|
655
|
+
alt: attributeText(el.attributes?.alt),
|
|
656
|
+
title: attributeTitle(el.attributes?.title),
|
|
572
657
|
type: "image",
|
|
573
|
-
url: (el.attributes?.src
|
|
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
|
|
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
|
|
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
|
|
599
|
-
|
|
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
|
|
620
|
-
if (
|
|
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
|
|
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
|
|
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 {
|
|
783
|
+
* @param {TagNameSlot} tagName
|
|
704
784
|
* @returns {string} The literal tag, or "" when none is declared
|
|
705
785
|
*/
|
|
706
|
-
function serializableTag(tagName:
|
|
707
|
-
if (
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
: ((
|
|
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
|
-
|
|
785
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
923
|
+
name: prototype,
|
|
853
924
|
type: isBlock ? "containerDirective" : "textDirective",
|
|
854
925
|
};
|
|
855
926
|
}
|
|
856
927
|
|
|
857
|
-
function collapsePropsToAttrMap(propsObj:
|
|
928
|
+
function collapsePropsToAttrMap(propsObj: DirectiveProps) {
|
|
858
929
|
const result: Record<string, string> = {};
|
|
859
930
|
|
|
860
|
-
function walk(obj:
|
|
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 (
|
|
875
|
-
walk(value
|
|
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 (
|
|
886
|
-
walk(value
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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 (
|
|
988
|
+
if (isString(node)) {
|
|
913
989
|
return node.trim() ? [{ type: "text", value: node }] : [];
|
|
914
990
|
}
|
|
915
|
-
if (
|
|
991
|
+
if (isNumber(node)) {
|
|
916
992
|
return [{ type: "text", value: String(node) }];
|
|
917
993
|
}
|
|
918
|
-
if (!node
|
|
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 (
|
|
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
|
|
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
|
|
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 =
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const value = textOf(codeChild
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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,
|
|
1161
|
-
ctx: ExportContext
|
|
1162
|
-
): JxElement | string
|
|
1163
|
-
|
|
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
|
|
1251
|
+
if (!isNonNullObject(node)) {
|
|
1168
1252
|
return node;
|
|
1169
1253
|
}
|
|
1170
1254
|
|
|
1171
1255
|
const result = { ...node };
|
|
1172
1256
|
|
|
1173
|
-
if (
|
|
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 (
|
|
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 (
|
|
1272
|
+
if (isString(v)) {
|
|
1189
1273
|
// Template evaluation yields a substituted scalar for attribute values.
|
|
1190
|
-
const evaluated = ctx.evaluateTemplate?.(v, scope)
|
|
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,
|
|
1292
|
+
ctx: ExportContext<ScopeValue>,
|
|
1293
|
+
scope?: Record<string, ScopeValue> | null,
|
|
1210
1294
|
): MdastNode[] {
|
|
1211
|
-
const itemsValue =
|
|
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 =
|
|
1306
|
+
const mapTemplate = arrayDef.map;
|
|
1223
1307
|
if (!mapTemplate) {
|
|
1224
1308
|
return [];
|
|
1225
1309
|
}
|
|
1226
1310
|
|
|
1227
|
-
return items.flatMap((
|
|
1228
|
-
const resolved = resolveMapNode(mapTemplate,
|
|
1229
|
-
return nodeToMdast(resolved
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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 &&
|
|
1245
|
-
const
|
|
1246
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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 (
|
|
1264
|
-
|
|
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
|
-
|
|
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 =
|
|
1358
|
+
let current = root;
|
|
1281
1359
|
for (const part of parts) {
|
|
1282
1360
|
if (current == null) {
|
|
1283
1361
|
return;
|
|
1284
1362
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
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,
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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:
|
|
1300
|
-
ctx: ExportContext
|
|
1301
|
-
scope?: Record<string,
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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("---",
|
|
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
|
|
1666
|
-
const processor = (
|
|
1768
|
+
const mdast = jxToMdast(doc, opts);
|
|
1769
|
+
const processor = createProcessor()
|
|
1667
1770
|
.use(remarkGfm)
|
|
1668
1771
|
.use(remarkDirective)
|
|
1669
|
-
.use(
|
|
1670
|
-
const md = processor.stringify(mdast
|
|
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
|
-
}
|
|
1724
|
-
|
|
1725
|
-
const processor = (
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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`;
|