@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.
Files changed (3) hide show
  1. package/package.json +11 -2
  2. package/src/md.js +17 -1
  3. 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.1",
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
- "./MarkdownCollection.class.json": "./src/MarkdownCollection.class.json"
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
- data.hProperties = { ...node.attributes };
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) {
@@ -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
+ }