@jxsuite/parser 0.0.1 → 0.5.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/package.json +11 -2
- package/src/md.js +17 -1
- package/src/transpile.js +590 -0
package/package.json
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/parser",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Jx markdown parser and external class integration",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/parser"
|
|
10
|
+
},
|
|
6
11
|
"files": [
|
|
7
12
|
"src/"
|
|
8
13
|
],
|
|
9
14
|
"type": "module",
|
|
10
15
|
"exports": {
|
|
11
16
|
".": "./src/md.js",
|
|
17
|
+
"./MarkdownCollection.class.json": "./src/MarkdownCollection.class.json",
|
|
12
18
|
"./MarkdownFile.class.json": "./src/MarkdownFile.class.json",
|
|
13
|
-
"./
|
|
19
|
+
"./transpile": "./src/transpile.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"provenance": true
|
|
14
23
|
},
|
|
15
24
|
"scripts": {
|
|
16
25
|
"test": "bun test",
|
package/src/md.js
CHANGED
|
@@ -312,6 +312,20 @@ export class MarkdownCollection {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// ─── Jx Markdown Transpiler (re-exported from browser-safe module) ──────────
|
|
316
|
+
|
|
317
|
+
export {
|
|
318
|
+
expandDotPaths,
|
|
319
|
+
collapseDotPaths,
|
|
320
|
+
expandStylePaths,
|
|
321
|
+
collapseStylePaths,
|
|
322
|
+
applyStyleKeyMapping,
|
|
323
|
+
isJxMarkdown,
|
|
324
|
+
transpileJxMarkdown,
|
|
325
|
+
jxKey,
|
|
326
|
+
mdKey,
|
|
327
|
+
} from "./transpile.js";
|
|
328
|
+
|
|
315
329
|
// ─── MarkdownDirective ────────────────────────────────────────────────────────
|
|
316
330
|
|
|
317
331
|
/**
|
|
@@ -355,7 +369,9 @@ export function MarkdownDirective(options = {}) {
|
|
|
355
369
|
// Set hast properties for remarkRehype
|
|
356
370
|
const data = node.data || (node.data = {});
|
|
357
371
|
data.hName = tagName;
|
|
358
|
-
|
|
372
|
+
const attrs = node.attributes;
|
|
373
|
+
data.hProperties =
|
|
374
|
+
attrs && Object.keys(attrs).length > 0 ? { "data-jx-props": JSON.stringify(attrs) } : {};
|
|
359
375
|
|
|
360
376
|
// For text directives, preserve label as children
|
|
361
377
|
if (node.type === "textDirective" && node.children?.length > 0) {
|
package/src/transpile.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jx Markdown Transpiler — Browser-safe module
|
|
3
|
+
*
|
|
4
|
+
* Exports only the transpiler functions that work in browser environments
|
|
5
|
+
* (no node:fs, node:path, or glob dependencies).
|
|
6
|
+
*
|
|
7
|
+
* Use `@jxsuite/parser/transpile` to import in browser contexts (e.g. studio).
|
|
8
|
+
* Use `@jxsuite/parser` for the full parser including MarkdownFile/MarkdownCollection.
|
|
9
|
+
*
|
|
10
|
+
* @module @jxsuite/parser/transpile
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { unified } from "unified";
|
|
15
|
+
import remarkParse from "remark-parse";
|
|
16
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
17
|
+
import remarkParseFrontmatter from "remark-parse-frontmatter";
|
|
18
|
+
import remarkGfm from "remark-gfm";
|
|
19
|
+
import remarkDirective from "remark-directive";
|
|
20
|
+
|
|
21
|
+
// ─── Dot-path expansion ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Jx reserved keywords that need `$` prefix in directive attributes. Only includes keywords with no
|
|
25
|
+
* DOM/HTML property collision.
|
|
26
|
+
*/
|
|
27
|
+
const JX_DOLLAR_KEYS = new Set(["prototype", "ref", "component", "props", "switch", "elements"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Re-add `$` prefix to known Jx reserved keywords.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} key
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function jxKey(key) {
|
|
36
|
+
return JX_DOLLAR_KEYS.has(key) ? `$${key}` : key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip `$` prefix from Jx reserved keywords for markdown attribute output.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} key
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function mdKey(key) {
|
|
46
|
+
if (key.startsWith("$") && JX_DOLLAR_KEYS.has(key.slice(1))) {
|
|
47
|
+
return key.slice(1);
|
|
48
|
+
}
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Expand dot-path attribute keys into nested objects.
|
|
54
|
+
*
|
|
55
|
+
* @param {Record<string, string>} attrs - Flat attribute map from remark-directive
|
|
56
|
+
* @returns {Record<string, any>} Nested object
|
|
57
|
+
*/
|
|
58
|
+
export function expandDotPaths(attrs) {
|
|
59
|
+
/** @type {Record<string, any>} */
|
|
60
|
+
const result = {};
|
|
61
|
+
|
|
62
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
63
|
+
const dotIndex = key.indexOf(".");
|
|
64
|
+
if (dotIndex === -1) {
|
|
65
|
+
result[jxKey(key)] = value;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const segments = key.split(".");
|
|
70
|
+
let target = result;
|
|
71
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
72
|
+
const seg = jxKey(segments[i]);
|
|
73
|
+
if (!(seg in target) || typeof target[seg] !== "object") {
|
|
74
|
+
target[seg] = {};
|
|
75
|
+
}
|
|
76
|
+
target = target[seg];
|
|
77
|
+
}
|
|
78
|
+
target[jxKey(segments[segments.length - 1])] = value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Collapse a nested object back to dot-path flat attributes (inverse of expandDotPaths).
|
|
86
|
+
*
|
|
87
|
+
* @param {Record<string, any>} obj - Nested object
|
|
88
|
+
* @returns {Record<string, string>} Flat attribute map
|
|
89
|
+
*/
|
|
90
|
+
export function collapseDotPaths(obj) {
|
|
91
|
+
/** @type {Record<string, string>} */
|
|
92
|
+
const result = {};
|
|
93
|
+
|
|
94
|
+
function walk(/** @type {Record<string, any>} */ node, /** @type {string} */ prefix) {
|
|
95
|
+
for (const [key, value] of Object.entries(node)) {
|
|
96
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
97
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
98
|
+
walk(value, path);
|
|
99
|
+
} else {
|
|
100
|
+
result[path] = String(value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
walk(obj, "");
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** CSS pseudo-class / pseudo-element names (keys that become `:` prefixed in style objects). */
|
|
110
|
+
const CSS_PSEUDO_NAMES = new Set([
|
|
111
|
+
"hover",
|
|
112
|
+
"focus",
|
|
113
|
+
"active",
|
|
114
|
+
"visited",
|
|
115
|
+
"disabled",
|
|
116
|
+
"checked",
|
|
117
|
+
"valid",
|
|
118
|
+
"invalid",
|
|
119
|
+
"required",
|
|
120
|
+
"empty",
|
|
121
|
+
"first-child",
|
|
122
|
+
"last-child",
|
|
123
|
+
"focus-within",
|
|
124
|
+
"focus-visible",
|
|
125
|
+
"placeholder",
|
|
126
|
+
"selection",
|
|
127
|
+
"before",
|
|
128
|
+
"after",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Apply CSS pseudo-class and media query key mapping to a style object's top-level keys.
|
|
133
|
+
*
|
|
134
|
+
* Transforms keys that cannot use `:` or `@` prefixes in remark-directive attributes: - `hover` →
|
|
135
|
+
* `:hover` (for known CSS pseudo-class names) - `--dark` → `@--dark` (for custom property / media
|
|
136
|
+
* query keys)
|
|
137
|
+
*
|
|
138
|
+
* @param {Record<string, any>} styleObj
|
|
139
|
+
* @returns {Record<string, any>}
|
|
140
|
+
*/
|
|
141
|
+
export function applyStyleKeyMapping(styleObj) {
|
|
142
|
+
/** @type {Record<string, any>} */
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const [key, value] of Object.entries(styleObj)) {
|
|
145
|
+
if (CSS_PSEUDO_NAMES.has(key)) {
|
|
146
|
+
result[`:${key}`] = value;
|
|
147
|
+
} else if (key.startsWith("--")) {
|
|
148
|
+
result[`@${key}`] = value;
|
|
149
|
+
} else {
|
|
150
|
+
result[key] = value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Expand dot-path attributes with style-aware key mapping.
|
|
158
|
+
*
|
|
159
|
+
* Maps known CSS pseudo-class names → `:` prefix and `--` keys → `@` prefix, since `:` and `@`
|
|
160
|
+
* cannot appear at the start of remark-directive attribute keys.
|
|
161
|
+
*
|
|
162
|
+
* @param {Record<string, string>} attrs
|
|
163
|
+
* @returns {Record<string, any>}
|
|
164
|
+
*/
|
|
165
|
+
export function expandStylePaths(attrs) {
|
|
166
|
+
return applyStyleKeyMapping(expandDotPaths(attrs));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Collapse a style object back to flat dot-path attributes (inverse of expandStylePaths).
|
|
171
|
+
*
|
|
172
|
+
* Strips `:` prefix from pseudo-class keys and `@` prefix from media keys before flattening with
|
|
173
|
+
* collapseDotPaths.
|
|
174
|
+
*
|
|
175
|
+
* @param {Record<string, any>} styleObj
|
|
176
|
+
* @returns {Record<string, string>}
|
|
177
|
+
*/
|
|
178
|
+
export function collapseStylePaths(styleObj) {
|
|
179
|
+
/** @type {Record<string, any>} */
|
|
180
|
+
const normalized = {};
|
|
181
|
+
|
|
182
|
+
for (const [key, value] of Object.entries(styleObj)) {
|
|
183
|
+
if (key.startsWith(":") && CSS_PSEUDO_NAMES.has(key.slice(1))) {
|
|
184
|
+
normalized[key.slice(1)] = value;
|
|
185
|
+
} else if (key.startsWith("@--")) {
|
|
186
|
+
normalized[key.slice(1)] = value;
|
|
187
|
+
} else {
|
|
188
|
+
normalized[key] = value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return collapseDotPaths(normalized);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Detection ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if a markdown source string is a Jx component (vs content markdown). Returns true if
|
|
199
|
+
* frontmatter contains a `tagName` key with a hyphen.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} source - Raw markdown string
|
|
202
|
+
* @returns {boolean}
|
|
203
|
+
*/
|
|
204
|
+
export function isJxMarkdown(source) {
|
|
205
|
+
const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
206
|
+
if (!fmMatch) return false;
|
|
207
|
+
return /^tagName:\s*.+-.+/m.test(fmMatch[1]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Transpiler ─────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/** HTML attributes that go into the `attributes` sub-object (not top-level DOM properties). */
|
|
213
|
+
const HTML_ATTR_PATTERN = /^(?:aria-|data-|slot$)/;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Elements with phrasing content model — cannot contain <p> elements. When these appear as
|
|
217
|
+
* container directives, paragraph children from the markdown parser are unwrapped (their inline
|
|
218
|
+
* children promoted directly).
|
|
219
|
+
*/
|
|
220
|
+
const PHRASING_ELEMENTS = new Set([
|
|
221
|
+
"p",
|
|
222
|
+
"h1",
|
|
223
|
+
"h2",
|
|
224
|
+
"h3",
|
|
225
|
+
"h4",
|
|
226
|
+
"h5",
|
|
227
|
+
"h6",
|
|
228
|
+
"span",
|
|
229
|
+
"a",
|
|
230
|
+
"em",
|
|
231
|
+
"strong",
|
|
232
|
+
"b",
|
|
233
|
+
"i",
|
|
234
|
+
"u",
|
|
235
|
+
"s",
|
|
236
|
+
"small",
|
|
237
|
+
"sub",
|
|
238
|
+
"sup",
|
|
239
|
+
"mark",
|
|
240
|
+
"abbr",
|
|
241
|
+
"cite",
|
|
242
|
+
"q",
|
|
243
|
+
"dfn",
|
|
244
|
+
"time",
|
|
245
|
+
"var",
|
|
246
|
+
"samp",
|
|
247
|
+
"kbd",
|
|
248
|
+
"data",
|
|
249
|
+
"code",
|
|
250
|
+
"label",
|
|
251
|
+
"button",
|
|
252
|
+
"legend",
|
|
253
|
+
"summary",
|
|
254
|
+
"dt",
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Route directive attributes to their correct Jx locations.
|
|
259
|
+
*
|
|
260
|
+
* @param {Record<string, string>} attrs
|
|
261
|
+
* @returns {{ props: Record<string, any>; attributes: Record<string, string> }}
|
|
262
|
+
*/
|
|
263
|
+
function routeAttributes(attrs) {
|
|
264
|
+
const expanded = expandDotPaths(attrs);
|
|
265
|
+
|
|
266
|
+
// Apply style-key mapping (pseudo-classes, media queries) to the style sub-object
|
|
267
|
+
if (expanded.style && typeof expanded.style === "object") {
|
|
268
|
+
expanded.style = applyStyleKeyMapping(expanded.style);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @type {Record<string, any>} */
|
|
272
|
+
const props = {};
|
|
273
|
+
/** @type {Record<string, string>} */
|
|
274
|
+
const attributes = {};
|
|
275
|
+
|
|
276
|
+
for (const [key, value] of Object.entries(expanded)) {
|
|
277
|
+
if (HTML_ATTR_PATTERN.test(key)) {
|
|
278
|
+
attributes[key] = value;
|
|
279
|
+
} else {
|
|
280
|
+
props[key] = value;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { props, attributes };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Mdast node-type → Jx tagName mapping.
|
|
289
|
+
*
|
|
290
|
+
* @type {Record<string, (n: any) => string>}
|
|
291
|
+
*/
|
|
292
|
+
const JX_TAG_MAP = {
|
|
293
|
+
heading: (/** @type {any} */ n) => `h${n.depth}`,
|
|
294
|
+
paragraph: () => "p",
|
|
295
|
+
emphasis: () => "em",
|
|
296
|
+
strong: () => "strong",
|
|
297
|
+
delete: () => "del",
|
|
298
|
+
inlineCode: () => "code",
|
|
299
|
+
link: () => "a",
|
|
300
|
+
image: () => "img",
|
|
301
|
+
blockquote: () => "blockquote",
|
|
302
|
+
list: (/** @type {any} */ n) => (n.ordered ? "ol" : "ul"),
|
|
303
|
+
listItem: () => "li",
|
|
304
|
+
code: () => "pre",
|
|
305
|
+
thematicBreak: () => "hr",
|
|
306
|
+
break: () => "br",
|
|
307
|
+
table: () => "table",
|
|
308
|
+
tableRow: () => "tr",
|
|
309
|
+
tableCell: (/** @type {any} */ n) => (n.isHeader ? "th" : "td"),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Convert a standard mdast node to a Jx element definition.
|
|
314
|
+
*
|
|
315
|
+
* @param {any} node
|
|
316
|
+
* @returns {any} Jx element or null
|
|
317
|
+
*/
|
|
318
|
+
function mdastNodeToJx(node) {
|
|
319
|
+
if (!node || typeof node !== "object") return null;
|
|
320
|
+
|
|
321
|
+
if (node.type === "yaml" || node.type === "toml") return null;
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
node.type === "containerDirective" ||
|
|
325
|
+
node.type === "leafDirective" ||
|
|
326
|
+
node.type === "textDirective"
|
|
327
|
+
) {
|
|
328
|
+
return directiveToJx(node);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (node.type === "text") {
|
|
332
|
+
return node.value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const tagFn = JX_TAG_MAP[node.type];
|
|
336
|
+
if (!tagFn) return null;
|
|
337
|
+
|
|
338
|
+
const tag = tagFn(node);
|
|
339
|
+
/** @type {Record<string, any>} */
|
|
340
|
+
const el = { tagName: tag };
|
|
341
|
+
|
|
342
|
+
switch (node.type) {
|
|
343
|
+
case "heading":
|
|
344
|
+
case "paragraph":
|
|
345
|
+
case "emphasis":
|
|
346
|
+
case "strong":
|
|
347
|
+
case "delete":
|
|
348
|
+
case "blockquote":
|
|
349
|
+
case "listItem":
|
|
350
|
+
case "tableRow":
|
|
351
|
+
case "tableCell": {
|
|
352
|
+
const children = convertChildren(node.children);
|
|
353
|
+
if (children.length === 1 && typeof children[0] === "string") {
|
|
354
|
+
el.textContent = children[0];
|
|
355
|
+
} else if (children.length > 0) {
|
|
356
|
+
el.children = children;
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case "inlineCode":
|
|
362
|
+
el.textContent = node.value;
|
|
363
|
+
break;
|
|
364
|
+
|
|
365
|
+
case "link":
|
|
366
|
+
el.attributes = { href: node.url };
|
|
367
|
+
if (node.title) el.attributes.title = node.title;
|
|
368
|
+
{
|
|
369
|
+
const children = convertChildren(node.children);
|
|
370
|
+
if (children.length === 1 && typeof children[0] === "string") {
|
|
371
|
+
el.textContent = children[0];
|
|
372
|
+
} else if (children.length > 0) {
|
|
373
|
+
el.children = children;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
|
|
378
|
+
case "image":
|
|
379
|
+
el.attributes = { src: node.url, alt: node.alt ?? "" };
|
|
380
|
+
if (node.title) el.attributes.title = node.title;
|
|
381
|
+
break;
|
|
382
|
+
|
|
383
|
+
case "list":
|
|
384
|
+
if (node.children?.length > 0) {
|
|
385
|
+
el.children = convertChildren(node.children);
|
|
386
|
+
}
|
|
387
|
+
if (node.start != null && node.start !== 1) {
|
|
388
|
+
el.attributes = { start: String(node.start) };
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
|
|
392
|
+
case "code":
|
|
393
|
+
el.children = [
|
|
394
|
+
{
|
|
395
|
+
tagName: "code",
|
|
396
|
+
textContent: node.value,
|
|
397
|
+
...(node.lang ? { className: `language-${node.lang}` } : {}),
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
break;
|
|
401
|
+
|
|
402
|
+
case "thematicBreak":
|
|
403
|
+
case "break":
|
|
404
|
+
break;
|
|
405
|
+
|
|
406
|
+
case "table": {
|
|
407
|
+
const rows = convertChildren(node.children);
|
|
408
|
+
const thead = rows.length > 0 ? { tagName: "thead", children: [rows[0]] } : null;
|
|
409
|
+
const tbody = rows.length > 1 ? { tagName: "tbody", children: rows.slice(1) } : null;
|
|
410
|
+
el.children = [thead, tbody].filter(Boolean);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return el;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Convert a directive mdast node to a Jx element.
|
|
420
|
+
*
|
|
421
|
+
* @param {any} node
|
|
422
|
+
* @returns {any}
|
|
423
|
+
*/
|
|
424
|
+
function directiveToJx(node) {
|
|
425
|
+
/** @type {Record<string, any>} */
|
|
426
|
+
const el = { tagName: node.name };
|
|
427
|
+
|
|
428
|
+
if (node.attributes && Object.keys(node.attributes).length > 0) {
|
|
429
|
+
const { props, attributes } = routeAttributes(node.attributes);
|
|
430
|
+
const isCustomElement = node.name.includes("-");
|
|
431
|
+
if (isCustomElement) {
|
|
432
|
+
// For custom elements:
|
|
433
|
+
// - style, children, textContent, innerHTML, $-prefixed → element-level
|
|
434
|
+
// - props (from props.X dot-path) → $props (component state)
|
|
435
|
+
// - everything else → HTML attributes
|
|
436
|
+
for (const [key, value] of Object.entries(props)) {
|
|
437
|
+
if (
|
|
438
|
+
key === "style" ||
|
|
439
|
+
key === "children" ||
|
|
440
|
+
key === "textContent" ||
|
|
441
|
+
key === "innerHTML" ||
|
|
442
|
+
key.startsWith("$")
|
|
443
|
+
) {
|
|
444
|
+
el[key] = value;
|
|
445
|
+
} else if (key === "props") {
|
|
446
|
+
el.$props = value;
|
|
447
|
+
} else {
|
|
448
|
+
if (!el.attributes) el.attributes = {};
|
|
449
|
+
el.attributes[key] = value;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
// For standard HTML elements:
|
|
454
|
+
// - Jx structural keys (style, children, textContent, innerHTML, $-prefixed) → element-level
|
|
455
|
+
// - Known DOM properties that buildAttrs handles → element-level
|
|
456
|
+
// - Everything else → HTML attributes (src, href, width, height, type, alt, etc.)
|
|
457
|
+
for (const [key, value] of Object.entries(props)) {
|
|
458
|
+
if (
|
|
459
|
+
key === "style" ||
|
|
460
|
+
key === "children" ||
|
|
461
|
+
key === "textContent" ||
|
|
462
|
+
key === "innerHTML" ||
|
|
463
|
+
key === "id" ||
|
|
464
|
+
key === "className" ||
|
|
465
|
+
key === "hidden" ||
|
|
466
|
+
key === "tabIndex" ||
|
|
467
|
+
key === "lang" ||
|
|
468
|
+
key === "dir" ||
|
|
469
|
+
key.startsWith("$") ||
|
|
470
|
+
key.startsWith("on")
|
|
471
|
+
) {
|
|
472
|
+
el[key] = value;
|
|
473
|
+
} else {
|
|
474
|
+
if (!el.attributes) el.attributes = {};
|
|
475
|
+
el.attributes[key] = value;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (Object.keys(attributes).length > 0) {
|
|
480
|
+
el.attributes = { ...el.attributes, ...attributes };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (node.type === "textDirective") {
|
|
485
|
+
if (node.children?.length > 0) {
|
|
486
|
+
const children = convertChildren(node.children);
|
|
487
|
+
if (children.length === 1 && typeof children[0] === "string") {
|
|
488
|
+
el.textContent = children[0];
|
|
489
|
+
} else if (children.length > 0) {
|
|
490
|
+
el.children = children;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return el;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (node.type === "leafDirective") {
|
|
497
|
+
return el;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (node.children?.length > 0) {
|
|
501
|
+
/** @type {any[]} */
|
|
502
|
+
const jxChildren = [];
|
|
503
|
+
const isPhrasingParent = PHRASING_ELEMENTS.has(node.name);
|
|
504
|
+
|
|
505
|
+
for (const child of node.children) {
|
|
506
|
+
if (isPhrasingParent && child.type === "paragraph") {
|
|
507
|
+
// Unwrap: promote paragraph's inline children directly
|
|
508
|
+
for (const inline of child.children ?? []) {
|
|
509
|
+
const converted = mdastNodeToJx(inline);
|
|
510
|
+
if (converted != null) jxChildren.push(converted);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
const converted = mdastNodeToJx(child);
|
|
514
|
+
if (converted != null) jxChildren.push(converted);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Don't overwrite children if already set as an object by dot-path attributes
|
|
519
|
+
// (e.g. children.prototype="Array" children.items.ref="...")
|
|
520
|
+
if (el.children && typeof el.children === "object" && !Array.isArray(el.children)) {
|
|
521
|
+
// children was set to a descriptor object by dot-path expansion — keep it
|
|
522
|
+
} else if (jxChildren.length === 1 && typeof jxChildren[0] === "string") {
|
|
523
|
+
el.textContent = jxChildren[0];
|
|
524
|
+
} else if (jxChildren.length > 0) {
|
|
525
|
+
el.children = jxChildren;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return el;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Convert an array of mdast children to Jx elements/strings.
|
|
534
|
+
*
|
|
535
|
+
* @param {any[]} children
|
|
536
|
+
* @returns {any[]}
|
|
537
|
+
*/
|
|
538
|
+
function convertChildren(children) {
|
|
539
|
+
if (!children) return [];
|
|
540
|
+
return children.map(mdastNodeToJx).filter((c) => c != null);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Transpile a Jx Markdown source string into a complete Jx JSON document.
|
|
545
|
+
*
|
|
546
|
+
* Uses the standard remark-parse + remark-frontmatter + remark-directive pipeline (no rehype).
|
|
547
|
+
* Walks the mdast tree and emits a Jx document with the same shape as a .json component file.
|
|
548
|
+
*
|
|
549
|
+
* @param {string} source - Raw markdown string
|
|
550
|
+
* @returns {object} Complete Jx JSON document
|
|
551
|
+
*/
|
|
552
|
+
export function transpileJxMarkdown(source) {
|
|
553
|
+
const processor = unified()
|
|
554
|
+
.use(remarkParse)
|
|
555
|
+
.use(remarkFrontmatter, ["yaml"])
|
|
556
|
+
.use(remarkParseFrontmatter)
|
|
557
|
+
.use(remarkGfm)
|
|
558
|
+
.use(remarkDirective);
|
|
559
|
+
|
|
560
|
+
const tree = processor.parse(source);
|
|
561
|
+
const vfile = { data: {} };
|
|
562
|
+
processor.runSync(tree, vfile);
|
|
563
|
+
|
|
564
|
+
const frontmatter = /** @type {any} */ (vfile.data)?.frontmatter ?? {};
|
|
565
|
+
|
|
566
|
+
/** @type {Record<string, any>} */
|
|
567
|
+
const doc = {};
|
|
568
|
+
|
|
569
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
570
|
+
doc[key] = value;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const bodyNodes = tree.children.filter(
|
|
574
|
+
(/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml",
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
/** @type {any[]} */
|
|
578
|
+
const children = [];
|
|
579
|
+
|
|
580
|
+
for (const node of bodyNodes) {
|
|
581
|
+
const converted = mdastNodeToJx(node);
|
|
582
|
+
if (converted != null) children.push(converted);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (children.length > 0) {
|
|
586
|
+
doc.children = children;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return doc;
|
|
590
|
+
}
|