@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/dist/content-loader.js +620 -286
- package/dist/content.js +183 -30
- package/dist/markdown.js +641 -382
- package/dist/serialize.js +28852 -28615
- package/package.json +6 -5
- package/src/Content.class.json +2 -0
- 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 +702 -223
- 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 +26 -6
- package/src/html-to-jx.ts +3 -3
- package/src/inert.ts +7 -5
- package/src/markdown.ts +27 -23
- package/src/md-html.ts +3 -3
- package/src/md.ts +133 -64
- package/src/serialize.ts +544 -299
- package/src/source-style.ts +236 -0
- package/src/transpile.ts +182 -152
- package/src/types.ts +43 -11
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
|
-
*
|
|
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";
|
|
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
|
|
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 &&
|
|
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
|
-
}
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
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: (
|
|
247
|
-
.filter((n) => n.type !== "yaml" && n.type !== "toml")
|
|
248
|
-
|
|
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)
|
|
328
|
+
return convertMdastNode(mdast);
|
|
253
329
|
}
|
|
254
330
|
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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)[];
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
522
|
+
export function jxToMdast(
|
|
523
|
+
jx: JxElement,
|
|
524
|
+
opts: Pick<SerializeOptions, "allowlist"> = {},
|
|
525
|
+
): MdastRoot {
|
|
453
526
|
const allowlist = normalizeAllowlist(opts.allowlist);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
686
|
+
slot: JxDocumentValue | undefined,
|
|
488
687
|
isBlock: boolean,
|
|
489
688
|
allowlist: ReadonlySet<string>,
|
|
490
689
|
): MdastNode | null {
|
|
491
|
-
if (
|
|
492
|
-
return { type: "text", value: String(
|
|
690
|
+
if (isString(slot) || isNumber(slot)) {
|
|
691
|
+
return { type: "text", value: String(slot) };
|
|
493
692
|
}
|
|
494
|
-
if (!
|
|
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
|
-
|
|
501
|
-
|
|
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 ``, 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
|
|
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
|
|
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
|
|
773
|
+
title: attributeTitle(el.attributes?.title),
|
|
563
774
|
type: "link",
|
|
564
|
-
url: (el.attributes?.href
|
|
775
|
+
url: attributeText(el.attributes?.href),
|
|
565
776
|
};
|
|
566
777
|
}
|
|
567
778
|
|
|
568
779
|
case "image": {
|
|
569
780
|
return {
|
|
570
|
-
alt: (el.attributes?.alt
|
|
571
|
-
title: (el.attributes?.title
|
|
781
|
+
alt: attributeText(el.attributes?.alt),
|
|
782
|
+
title: attributeTitle(el.attributes?.title),
|
|
572
783
|
type: "image",
|
|
573
|
-
url: (el.attributes?.src
|
|
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
|
|
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
|
|
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
|
|
599
|
-
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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 {
|
|
891
|
+
* @param {TagNameSlot} tagName
|
|
704
892
|
* @returns {string} The literal tag, or "" when none is declared
|
|
705
893
|
*/
|
|
706
|
-
function serializableTag(tagName:
|
|
707
|
-
if (
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
: ((
|
|
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
|
-
|
|
785
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
1031
|
+
name: prototype,
|
|
853
1032
|
type: isBlock ? "containerDirective" : "textDirective",
|
|
854
1033
|
};
|
|
855
1034
|
}
|
|
856
1035
|
|
|
857
|
-
function collapsePropsToAttrMap(propsObj:
|
|
1036
|
+
function collapsePropsToAttrMap(propsObj: DirectiveProps) {
|
|
858
1037
|
const result: Record<string, string> = {};
|
|
859
1038
|
|
|
860
|
-
function walk(obj:
|
|
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 (
|
|
875
|
-
walk(value
|
|
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 (
|
|
886
|
-
walk(value
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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 (
|
|
1096
|
+
if (isString(node)) {
|
|
913
1097
|
return node.trim() ? [{ type: "text", value: node }] : [];
|
|
914
1098
|
}
|
|
915
|
-
if (
|
|
1099
|
+
if (isNumber(node)) {
|
|
916
1100
|
return [{ type: "text", value: String(node) }];
|
|
917
1101
|
}
|
|
918
|
-
if (!node
|
|
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 (
|
|
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
|
|
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
|
|
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 =
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const value = textOf(codeChild
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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,
|
|
1161
|
-
ctx: ExportContext
|
|
1162
|
-
): JxElement | string
|
|
1163
|
-
|
|
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
|
|
1359
|
+
if (!isNonNullObject(node)) {
|
|
1168
1360
|
return node;
|
|
1169
1361
|
}
|
|
1170
1362
|
|
|
1171
1363
|
const result = { ...node };
|
|
1172
1364
|
|
|
1173
|
-
if (
|
|
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 (
|
|
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 (
|
|
1380
|
+
if (isString(v)) {
|
|
1189
1381
|
// Template evaluation yields a substituted scalar for attribute values.
|
|
1190
|
-
const evaluated = ctx.evaluateTemplate?.(v, scope)
|
|
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,
|
|
1400
|
+
ctx: ExportContext<ScopeValue>,
|
|
1401
|
+
scope?: Record<string, ScopeValue> | null,
|
|
1210
1402
|
): MdastNode[] {
|
|
1211
|
-
const itemsValue =
|
|
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 =
|
|
1414
|
+
const mapTemplate = arrayDef.map;
|
|
1223
1415
|
if (!mapTemplate) {
|
|
1224
1416
|
return [];
|
|
1225
1417
|
}
|
|
1226
1418
|
|
|
1227
|
-
return items.flatMap((
|
|
1228
|
-
const resolved = resolveMapNode(mapTemplate,
|
|
1229
|
-
return nodeToMdast(resolved
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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 &&
|
|
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
|
-
}
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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 (
|
|
1264
|
-
|
|
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
|
-
|
|
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 =
|
|
1466
|
+
let current = root;
|
|
1281
1467
|
for (const part of parts) {
|
|
1282
1468
|
if (current == null) {
|
|
1283
1469
|
return;
|
|
1284
1470
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
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,
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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:
|
|
1300
|
-
ctx: ExportContext
|
|
1301
|
-
scope?: Record<string,
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
1666
|
-
const
|
|
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(
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
lines.push(
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
|
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
|
-
}
|
|
1970
|
+
};
|
|
1724
1971
|
|
|
1725
|
-
const processor = (
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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`;
|