@jxsuite/parser 0.18.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/parser",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Jx markdown parser and external class integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,9 @@
28
28
  "dependencies": {
29
29
  "glob": "^13.0.6",
30
30
  "hast-util-from-html": "^2.0.3",
31
+ "hast-util-whitespace": "^3.0.0",
31
32
  "mdast-util-to-string": "^4.0.0",
33
+ "property-information": "^7.1.0",
32
34
  "rehype-stringify": "^10.0.1",
33
35
  "remark-directive": "^4.0.0",
34
36
  "remark-frontmatter": "^5.0.0",
@@ -41,6 +43,7 @@
41
43
  },
42
44
  "devDependencies": {
43
45
  "@happy-dom/global-registrator": "^20.9.0",
44
- "@jxsuite/runtime": "^0.18.0"
46
+ "@jxsuite/runtime": "^0.20.0",
47
+ "@types/hast": "^3.0.4"
45
48
  }
46
49
  }
package/src/html-to-jx.js CHANGED
@@ -2,23 +2,25 @@ import { fromHtml } from "hast-util-from-html";
2
2
  import { whitespace } from "hast-util-whitespace";
3
3
  import { find, html as htmlInfo } from "property-information";
4
4
 
5
+ /** @typedef {import("hast").Nodes} HastNode */
6
+
5
7
  /**
6
8
  * Convert an HTML string into an array of Jx tree nodes.
7
9
  *
8
10
  * @param {string} htmlString
9
- * @returns {any[]}
11
+ * @returns {(JxElement | string)[]}
10
12
  */
11
13
  export function htmlToJx(htmlString) {
12
14
  const hast = fromHtml(htmlString, { fragment: true });
13
- return convertHastChildren(hast.children);
15
+ return convertHastChildren(/** @type {HastNode[]} */ (hast.children));
14
16
  }
15
17
 
16
18
  /**
17
- * @param {any[]} children
18
- * @returns {any[]}
19
+ * @param {HastNode[]} children
20
+ * @returns {(JxElement | string)[]}
19
21
  */
20
22
  function convertHastChildren(children) {
21
- /** @type {any[]} */
23
+ /** @type {(JxElement | string)[]} */
22
24
  const result = [];
23
25
  for (const child of children) {
24
26
  const converted = convertHastNode(child);
@@ -28,17 +30,17 @@ function convertHastChildren(children) {
28
30
  }
29
31
 
30
32
  /**
31
- * @param {any} node
32
- * @returns {any}
33
+ * @param {HastNode} node
34
+ * @returns {JxElement | string | null}
33
35
  */
34
36
  function convertHastNode(node) {
35
37
  if (node.type === "text") {
36
- if (whitespace(node)) return null;
37
- return node.value;
38
+ if (whitespace(/** @type {HastNode} */ (/** @type {unknown} */ (node)))) return null;
39
+ return node.value ?? null;
38
40
  }
39
41
 
40
42
  if (node.type === "element") {
41
- /** @type {Record<string, any>} */
43
+ /** @type {JxElement} */
42
44
  const el = { tagName: node.tagName };
43
45
 
44
46
  if (node.properties && Object.keys(node.properties).length > 0) {
@@ -52,7 +54,7 @@ function convertHastNode(node) {
52
54
  if (kids.length === 1 && typeof kids[0] === "string") {
53
55
  el.textContent = kids[0];
54
56
  } else if (kids.length > 0) {
55
- el.children = kids;
57
+ el.children = /** @type {JxElement[]} */ (kids);
56
58
  }
57
59
 
58
60
  return el;
@@ -62,7 +64,7 @@ function convertHastNode(node) {
62
64
  }
63
65
 
64
66
  /**
65
- * @param {Record<string, any>} properties
67
+ * @param {Record<string, unknown>} properties
66
68
  * @returns {{ style: Record<string, string>; attrs: Record<string, string> }}
67
69
  */
68
70
  function hastPropsToJx(properties) {
package/src/md.js CHANGED
@@ -27,7 +27,7 @@ import { mdastNodeToJx } from "./transpile.js";
27
27
  /**
28
28
  * Walk an AST tree, calling visitor for nodes matching the given type.
29
29
  *
30
- * @param {any} tree
30
+ * @param {object} tree
31
31
  * @param {string | function} typeOrVisitor
32
32
  * @param {function} [maybeVisitor]
33
33
  */
@@ -35,20 +35,20 @@ function visit(tree, typeOrVisitor, maybeVisitor) {
35
35
  const type = typeof typeOrVisitor === "string" ? typeOrVisitor : null;
36
36
  const visitor = type ? maybeVisitor : typeOrVisitor;
37
37
 
38
- function walk(/** @type {any} */ node) {
38
+ function walk(/** @type {MdastNode} */ node) {
39
39
  if (!node || typeof node !== "object") return;
40
40
  if (!type || node.type === type) /** @type {Function} */ (visitor)(node);
41
41
  if (Array.isArray(node.children)) {
42
42
  for (const child of node.children) walk(child);
43
43
  }
44
44
  }
45
- walk(tree);
45
+ walk(/** @type {MdastNode} */ (tree));
46
46
  }
47
47
 
48
48
  /**
49
49
  * Serialize an mdast tree to plain text.
50
50
  *
51
- * @param {any} node
51
+ * @param {MdastNode} node
52
52
  * @returns {string}
53
53
  */
54
54
  function mdastToString(node) {
@@ -81,7 +81,7 @@ function readingTime(text) {
81
81
  function extractToc(tree) {
82
82
  /** @type {{ depth: number; text: string; id: string }[]} */
83
83
  const entries = [];
84
- visit(tree, "heading", (/** @type {any} */ node) => {
84
+ visit(tree, "heading", (/** @type {MdastNode} */ node) => {
85
85
  const text = mdastToString(node);
86
86
  const id = text
87
87
  .toLowerCase()
@@ -89,7 +89,7 @@ function extractToc(tree) {
89
89
  .replace(/\s+/g, "-")
90
90
  .replace(/-+/g, "-")
91
91
  .replace(/^-|-$/g, "");
92
- entries.push({ depth: node.depth, text, id });
92
+ entries.push({ depth: /** @type {number} */ (node.depth), text, id });
93
93
  });
94
94
  return entries;
95
95
  }
@@ -101,9 +101,9 @@ function extractToc(tree) {
101
101
  * @returns {string} Plain text of first paragraph, or empty string
102
102
  */
103
103
  function extractExcerpt(tree) {
104
- /** @type {any} */
104
+ /** @type {MdastNode | null} */
105
105
  let firstParagraph = null;
106
- visit(tree, "paragraph", (/** @type {any} */ node) => {
106
+ visit(tree, "paragraph", (/** @type {MdastNode} */ node) => {
107
107
  if (!firstParagraph) firstParagraph = node;
108
108
  });
109
109
  if (!firstParagraph) return "";
@@ -117,8 +117,10 @@ function extractExcerpt(tree) {
117
117
  *
118
118
  * @param {string} source - Raw markdown string
119
119
  * @param {string} filePath - File path (for slug derivation)
120
- * @param {any} config - Processing options
121
- * @returns {object} MarkdownFileResult
120
+ * @param {object} config - Processing options
121
+ * @param {boolean} [config.directives] - Enable directive support
122
+ * @param {unknown} [config.directiveOptions] - Directive plugin options
123
+ * @returns {MarkdownFileResult}
122
124
  */
123
125
  function processMarkdown(source, filePath, config = {}) {
124
126
  let processor = unified()
@@ -133,18 +135,25 @@ function processMarkdown(source, filePath, config = {}) {
133
135
 
134
136
  const tree = processor.parse(source);
135
137
  const vfile = { data: {} };
136
- processor.runSync(tree, /** @type {any} */ (vfile));
138
+ // @ts-ignore - vfile shape satisfies unified's internal contract
139
+ processor.runSync(tree, vfile);
137
140
 
138
- const frontmatter = /** @type {any} */ (vfile.data).frontmatter ?? {};
139
- const plainText = mdastToString(tree);
141
+ const vfileData = /** @type {Record<string, unknown>} */ (vfile.data);
142
+ const frontmatter = /** @type {Record<string, unknown>} */ (vfileData.frontmatter ?? {});
143
+ const mdTree = /** @type {MdastNode} */ (/** @type {unknown} */ (tree));
144
+ const plainText = mdastToString(mdTree);
140
145
  const toc = extractToc(tree);
141
146
  const excerpt = extractExcerpt(tree);
142
147
  const slug = basename(filePath, extname(filePath));
143
148
 
144
- const bodyNodes = tree.children.filter(
145
- (/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml",
149
+ const bodyNodes = /** @type {MdastNode[]} */ (
150
+ tree.children.filter(
151
+ (/** @type {{ type: string }} */ n) => n.type !== "yaml" && n.type !== "toml",
152
+ )
153
+ );
154
+ const $children = /** @type {(JxElement | string)[]} */ (
155
+ bodyNodes.map(/** @type {(n: MdastNode) => any} */ (mdastNodeToJx)).filter(Boolean)
146
156
  );
147
- const $children = bodyNodes.map(mdastNodeToJx).filter(Boolean);
148
157
 
149
158
  return {
150
159
  slug,
@@ -161,12 +170,17 @@ function processMarkdown(source, filePath, config = {}) {
161
170
  /**
162
171
  * Resolve a dot-notation path within an object.
163
172
  *
164
- * @param {any} obj
173
+ * @param {Record<string, unknown> | MarkdownFileResult} obj
165
174
  * @param {string} path
166
- * @returns {any}
175
+ * @returns {unknown}
167
176
  */
168
177
  function getNestedValue(obj, path) {
169
- return path.split(".").reduce((/** @type {any} */ o, k) => o?.[k], obj);
178
+ return path
179
+ .split(".")
180
+ .reduce(
181
+ (/** @type {unknown} */ o, k) => /** @type {Record<string, unknown> | undefined} */ (o)?.[k],
182
+ /** @type {unknown} */ (obj),
183
+ );
170
184
  }
171
185
 
172
186
  // ─── MarkdownFile ─────────────────────────────────────────────────────────────
@@ -181,8 +195,8 @@ export class MarkdownFile {
181
195
  /**
182
196
  * @param {object} config
183
197
  * @param {string} config.src - File path to markdown file
184
- * @param {any[]} [config.remarkPlugins] Default is `[]`
185
- * @param {any[]} [config.rehypePlugins] Default is `[]`
198
+ * @param {unknown[]} [config.remarkPlugins] Default is `[]`
199
+ * @param {unknown[]} [config.rehypePlugins] Default is `[]`
186
200
  * @param {string} [config.basePath] - Base path for resolving src
187
201
  * @param {boolean} [config.directives] - Enable directive support
188
202
  */
@@ -193,7 +207,7 @@ export class MarkdownFile {
193
207
  /**
194
208
  * Parse and resolve the markdown file.
195
209
  *
196
- * @returns {object} MarkdownFileResult
210
+ * @returns {MarkdownFileResult}
197
211
  */
198
212
  resolve() {
199
213
  const { src, basePath, ...processorConfig } = this.config;
@@ -220,8 +234,8 @@ export class MarkdownCollection {
220
234
  * @param {string} [config.sortOrder] Default is `'desc'`
221
235
  * @param {number} [config.limit]
222
236
  * @param {Function} [config.filter] - Filter function
223
- * @param {any[]} [config.remarkPlugins] Default is `[]`
224
- * @param {any[]} [config.rehypePlugins] Default is `[]`
237
+ * @param {unknown[]} [config.remarkPlugins] Default is `[]`
238
+ * @param {unknown[]} [config.rehypePlugins] Default is `[]`
225
239
  * @param {string} [config.basePath] - Base path for resolving glob
226
240
  * @param {boolean} [config.directives] - Enable directive support
227
241
  */
@@ -232,7 +246,7 @@ export class MarkdownCollection {
232
246
  /**
233
247
  * Glob files, parse each, sort, filter, and limit.
234
248
  *
235
- * @returns {Promise<object[]>} Array of MarkdownFileResult
249
+ * @returns {Promise<MarkdownFileResult[]>} Array of MarkdownFileResult
236
250
  */
237
251
  async resolve() {
238
252
  const {
@@ -250,7 +264,7 @@ export class MarkdownCollection {
250
264
  const pattern = resolved.split("\\").join("/");
251
265
  const files = globSync(pattern, { absolute: true });
252
266
 
253
- const results = files.map((filePath) => {
267
+ const results = files.map((/** @type {string} */ filePath) => {
254
268
  const source = readFileSync(filePath, "utf-8");
255
269
  return processMarkdown(source, filePath, processorConfig);
256
270
  });
@@ -258,13 +272,13 @@ export class MarkdownCollection {
258
272
  // Filter
259
273
  let filtered = results;
260
274
  if (typeof filter === "function") {
261
- filtered = results.filter(/** @type {any} */ (filter));
275
+ filtered = results.filter(/** @type {(r: MarkdownFileResult) => boolean} */ (filter));
262
276
  }
263
277
 
264
278
  // Sort
265
- filtered.sort((a, b) => {
266
- const aVal = getNestedValue(a, sortBy) ?? "";
267
- const bVal = getNestedValue(b, sortBy) ?? "";
279
+ filtered.sort((/** @type {MarkdownFileResult} */ a, /** @type {MarkdownFileResult} */ b) => {
280
+ const aVal = /** @type {string | number} */ (getNestedValue(a, sortBy) ?? "");
281
+ const bVal = /** @type {string | number} */ (getNestedValue(b, sortBy) ?? "");
268
282
  if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
269
283
  if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
270
284
  return 0;
package/src/transpile.js CHANGED
@@ -29,13 +29,21 @@ export { htmlToJx };
29
29
  const JX_DOLLAR_KEYS = new Set(["prototype", "ref", "component", "props", "switch", "elements"]);
30
30
 
31
31
  /**
32
- * Re-add `$` prefix to known Jx reserved keywords.
32
+ * Annotation keys written as `--key` in markdown directives, mapped to `$key` in JX JSON. These use
33
+ * `--` prefix to avoid collision with HTML attributes like `title`.
34
+ */
35
+ const JX_ANNOTATION_KEYS = new Set(["title", "description"]);
36
+
37
+ /**
38
+ * Re-add `$` prefix to known Jx reserved keywords and annotation keys.
33
39
  *
34
40
  * @param {string} key
35
41
  * @returns {string}
36
42
  */
37
43
  export function jxKey(key) {
38
- return JX_DOLLAR_KEYS.has(key) ? `$${key}` : key;
44
+ if (JX_DOLLAR_KEYS.has(key)) return `$${key}`;
45
+ if (key.startsWith("--") && JX_ANNOTATION_KEYS.has(key.slice(2))) return `$${key.slice(2)}`;
46
+ return key;
39
47
  }
40
48
 
41
49
  /**
@@ -48,6 +56,9 @@ export function mdKey(key) {
48
56
  if (key.startsWith("$") && JX_DOLLAR_KEYS.has(key.slice(1))) {
49
57
  return key.slice(1);
50
58
  }
59
+ if (key.startsWith("$") && JX_ANNOTATION_KEYS.has(key.slice(1))) {
60
+ return `--${key.slice(1)}`;
61
+ }
51
62
  return key;
52
63
  }
53
64
 
@@ -55,10 +66,10 @@ export function mdKey(key) {
55
66
  * Expand dot-path attribute keys into nested objects.
56
67
  *
57
68
  * @param {Record<string, string>} attrs - Flat attribute map from remark-directive
58
- * @returns {Record<string, any>} Nested object
69
+ * @returns {Record<string, unknown>} Nested object
59
70
  */
60
71
  export function expandDotPaths(attrs) {
61
- /** @type {Record<string, any>} */
72
+ /** @type {Record<string, unknown>} */
62
73
  const result = {};
63
74
 
64
75
  for (const [key, value] of Object.entries(attrs)) {
@@ -75,7 +86,7 @@ export function expandDotPaths(attrs) {
75
86
  if (!(seg in target) || typeof target[seg] !== "object") {
76
87
  target[seg] = {};
77
88
  }
78
- target = target[seg];
89
+ target = /** @type {Record<string, unknown>} */ (target[seg]);
79
90
  }
80
91
  target[jxKey(segments[segments.length - 1])] = value;
81
92
  }
@@ -86,18 +97,18 @@ export function expandDotPaths(attrs) {
86
97
  /**
87
98
  * Collapse a nested object back to dot-path flat attributes (inverse of expandDotPaths).
88
99
  *
89
- * @param {Record<string, any>} obj - Nested object
100
+ * @param {Record<string, unknown>} obj - Nested object
90
101
  * @returns {Record<string, string>} Flat attribute map
91
102
  */
92
103
  export function collapseDotPaths(obj) {
93
104
  /** @type {Record<string, string>} */
94
105
  const result = {};
95
106
 
96
- function walk(/** @type {Record<string, any>} */ node, /** @type {string} */ prefix) {
107
+ function walk(/** @type {Record<string, unknown>} */ node, /** @type {string} */ prefix) {
97
108
  for (const [key, value] of Object.entries(node)) {
98
109
  const path = prefix ? `${prefix}.${key}` : key;
99
110
  if (value && typeof value === "object" && !Array.isArray(value)) {
100
- walk(value, path);
111
+ walk(/** @type {Record<string, unknown>} */ (value), path);
101
112
  } else {
102
113
  result[path] = String(value);
103
114
  }
@@ -137,11 +148,11 @@ const CSS_PSEUDO_NAMES = new Set([
137
148
  * `:hover` (for known CSS pseudo-class names) - `--dark` → `@--dark` (for custom property / media
138
149
  * query keys)
139
150
  *
140
- * @param {Record<string, any>} styleObj
141
- * @returns {Record<string, any>}
151
+ * @param {Record<string, unknown>} styleObj
152
+ * @returns {Record<string, unknown>}
142
153
  */
143
154
  export function applyStyleKeyMapping(styleObj) {
144
- /** @type {Record<string, any>} */
155
+ /** @type {Record<string, unknown>} */
145
156
  const result = {};
146
157
  for (const [key, value] of Object.entries(styleObj)) {
147
158
  if (CSS_PSEUDO_NAMES.has(key)) {
@@ -162,7 +173,7 @@ export function applyStyleKeyMapping(styleObj) {
162
173
  * cannot appear at the start of remark-directive attribute keys.
163
174
  *
164
175
  * @param {Record<string, string>} attrs
165
- * @returns {Record<string, any>}
176
+ * @returns {Record<string, unknown>}
166
177
  */
167
178
  export function expandStylePaths(attrs) {
168
179
  return applyStyleKeyMapping(expandDotPaths(attrs));
@@ -174,11 +185,11 @@ export function expandStylePaths(attrs) {
174
185
  * Strips `:` prefix from pseudo-class keys and `@` prefix from media keys before flattening with
175
186
  * collapseDotPaths.
176
187
  *
177
- * @param {Record<string, any>} styleObj
188
+ * @param {Record<string, unknown>} styleObj
178
189
  * @returns {Record<string, string>}
179
190
  */
180
191
  export function collapseStylePaths(styleObj) {
181
- /** @type {Record<string, any>} */
192
+ /** @type {Record<string, unknown>} */
182
193
  const normalized = {};
183
194
 
184
195
  for (const [key, value] of Object.entries(styleObj)) {
@@ -260,24 +271,24 @@ const PHRASING_ELEMENTS = new Set([
260
271
  * Route directive attributes to their correct Jx locations.
261
272
  *
262
273
  * @param {Record<string, string>} attrs
263
- * @returns {{ props: Record<string, any>; attributes: Record<string, string> }}
274
+ * @returns {{ props: Record<string, unknown>; attributes: Record<string, string> }}
264
275
  */
265
276
  function routeAttributes(attrs) {
266
277
  const expanded = expandDotPaths(attrs);
267
278
 
268
279
  // Apply style-key mapping (pseudo-classes, media queries) to the style sub-object
269
280
  if (expanded.style && typeof expanded.style === "object") {
270
- expanded.style = applyStyleKeyMapping(expanded.style);
281
+ expanded.style = applyStyleKeyMapping(/** @type {Record<string, unknown>} */ (expanded.style));
271
282
  }
272
283
 
273
- /** @type {Record<string, any>} */
284
+ /** @type {Record<string, unknown>} */
274
285
  const props = {};
275
286
  /** @type {Record<string, string>} */
276
287
  const attributes = {};
277
288
 
278
289
  for (const [key, value] of Object.entries(expanded)) {
279
290
  if (HTML_ATTR_PATTERN.test(key)) {
280
- attributes[key] = value;
291
+ attributes[key] = /** @type {string} */ (value);
281
292
  } else {
282
293
  props[key] = value;
283
294
  }
@@ -289,10 +300,10 @@ function routeAttributes(attrs) {
289
300
  /**
290
301
  * Mdast node-type → Jx tagName mapping.
291
302
  *
292
- * @type {Record<string, (n: any) => string>}
303
+ * @type {Record<string, (n: MdastNode) => string>}
293
304
  */
294
305
  const JX_TAG_MAP = {
295
- heading: (/** @type {any} */ n) => `h${n.depth}`,
306
+ heading: (/** @type {MdastNode} */ n) => `h${n.depth}`,
296
307
  paragraph: () => "p",
297
308
  emphasis: () => "em",
298
309
  strong: () => "strong",
@@ -301,21 +312,21 @@ const JX_TAG_MAP = {
301
312
  link: () => "a",
302
313
  image: () => "img",
303
314
  blockquote: () => "blockquote",
304
- list: (/** @type {any} */ n) => (n.ordered ? "ol" : "ul"),
315
+ list: (/** @type {MdastNode} */ n) => (n.ordered ? "ol" : "ul"),
305
316
  listItem: () => "li",
306
317
  code: () => "pre",
307
318
  thematicBreak: () => "hr",
308
319
  break: () => "br",
309
320
  table: () => "table",
310
321
  tableRow: () => "tr",
311
- tableCell: (/** @type {any} */ n) => (n.isHeader ? "th" : "td"),
322
+ tableCell: (/** @type {MdastNode} */ n) => (n.isHeader ? "th" : "td"),
312
323
  };
313
324
 
314
325
  /**
315
326
  * Convert a standard mdast node to a Jx element definition.
316
327
  *
317
- * @param {any} node
318
- * @returns {any} Jx element or null
328
+ * @param {MdastNode} node
329
+ * @returns {JxElement | string | (JxElement | string)[] | null} Jx element or null
319
330
  */
320
331
  export function mdastNodeToJx(node) {
321
332
  if (!node || typeof node !== "object") return null;
@@ -331,7 +342,7 @@ export function mdastNodeToJx(node) {
331
342
  }
332
343
 
333
344
  if (node.type === "text") {
334
- return node.value;
345
+ return node.value ?? null;
335
346
  }
336
347
 
337
348
  if (node.type === "html") {
@@ -343,7 +354,7 @@ export function mdastNodeToJx(node) {
343
354
  if (!tagFn) return null;
344
355
 
345
356
  const tag = tagFn(node);
346
- /** @type {Record<string, any>} */
357
+ /** @type {JxElement} */
347
358
  const el = { tagName: tag };
348
359
 
349
360
  switch (node.type) {
@@ -356,7 +367,7 @@ export function mdastNodeToJx(node) {
356
367
  case "listItem":
357
368
  case "tableRow":
358
369
  case "tableCell": {
359
- const children = convertChildren(node.children);
370
+ const children = convertChildren(node.children ?? []);
360
371
  if (children.length === 1 && typeof children[0] === "string") {
361
372
  el.textContent = children[0];
362
373
  } else if (children.length > 0) {
@@ -373,7 +384,7 @@ export function mdastNodeToJx(node) {
373
384
  el.attributes = { href: node.url };
374
385
  if (node.title) el.attributes.title = node.title;
375
386
  {
376
- const children = convertChildren(node.children);
387
+ const children = convertChildren(node.children ?? []);
377
388
  if (children.length === 1 && typeof children[0] === "string") {
378
389
  el.textContent = children[0];
379
390
  } else if (children.length > 0) {
@@ -388,7 +399,7 @@ export function mdastNodeToJx(node) {
388
399
  break;
389
400
 
390
401
  case "list":
391
- if (node.children?.length > 0) {
402
+ if (node.children && node.children.length > 0) {
392
403
  el.children = convertChildren(node.children);
393
404
  }
394
405
  if (node.start != null && node.start !== 1) {
@@ -411,10 +422,10 @@ export function mdastNodeToJx(node) {
411
422
  break;
412
423
 
413
424
  case "table": {
414
- const rows = convertChildren(node.children);
425
+ const rows = convertChildren(node.children ?? []);
415
426
  const thead = rows.length > 0 ? { tagName: "thead", children: [rows[0]] } : null;
416
427
  const tbody = rows.length > 1 ? { tagName: "tbody", children: rows.slice(1) } : null;
417
- el.children = [thead, tbody].filter(Boolean);
428
+ el.children = /** @type {JxElement[]} */ ([thead, tbody].filter(Boolean));
418
429
  break;
419
430
  }
420
431
  }
@@ -425,16 +436,16 @@ export function mdastNodeToJx(node) {
425
436
  /**
426
437
  * Convert a directive mdast node to a Jx element.
427
438
  *
428
- * @param {any} node
429
- * @returns {any}
439
+ * @param {MdastNode} node
440
+ * @returns {JxElement}
430
441
  */
431
442
  function directiveToJx(node) {
432
- /** @type {Record<string, any>} */
433
- const el = { tagName: node.name };
443
+ /** @type {JxElement} */
444
+ const el = { tagName: /** @type {string} */ (node.name) };
434
445
 
435
446
  if (node.attributes && Object.keys(node.attributes).length > 0) {
436
447
  const { props, attributes } = routeAttributes(node.attributes);
437
- const isCustomElement = node.name.includes("-");
448
+ const isCustomElement = /** @type {string} */ (node.name).includes("-");
438
449
  if (isCustomElement) {
439
450
  // For custom elements:
440
451
  // - style, children, textContent, innerHTML, $-prefixed → element-level
@@ -450,7 +461,7 @@ function directiveToJx(node) {
450
461
  ) {
451
462
  el[key] = value;
452
463
  } else if (key === "props") {
453
- el.$props = value;
464
+ el.$props = /** @type {Record<string, unknown>} */ (value);
454
465
  } else {
455
466
  if (!el.attributes) el.attributes = {};
456
467
  el.attributes[key] = value;
@@ -489,8 +500,8 @@ function directiveToJx(node) {
489
500
  }
490
501
 
491
502
  if (node.type === "textDirective") {
492
- if (node.children?.length > 0) {
493
- const children = convertChildren(node.children);
503
+ if (node.children && node.children.length > 0) {
504
+ const children = convertChildren(node.children ?? []);
494
505
  if (children.length === 1 && typeof children[0] === "string") {
495
506
  el.textContent = children[0];
496
507
  } else if (children.length > 0) {
@@ -504,21 +515,25 @@ function directiveToJx(node) {
504
515
  return el;
505
516
  }
506
517
 
507
- if (node.children?.length > 0) {
508
- /** @type {any[]} */
518
+ if (node.children && node.children.length > 0) {
519
+ /** @type {(JxElement | string)[]} */
509
520
  const jxChildren = [];
510
- const isPhrasingParent = PHRASING_ELEMENTS.has(node.name);
521
+ const isPhrasingParent = PHRASING_ELEMENTS.has(/** @type {string} */ (node.name));
511
522
 
512
523
  for (const child of node.children) {
513
524
  if (isPhrasingParent && child.type === "paragraph") {
514
525
  // Unwrap: promote paragraph's inline children directly
515
526
  for (const inline of child.children ?? []) {
516
527
  const converted = mdastNodeToJx(inline);
517
- if (converted != null) jxChildren.push(converted);
528
+ if (converted == null) continue;
529
+ if (Array.isArray(converted)) jxChildren.push(...converted);
530
+ else jxChildren.push(converted);
518
531
  }
519
532
  } else {
520
533
  const converted = mdastNodeToJx(child);
521
- if (converted != null) jxChildren.push(converted);
534
+ if (converted == null) continue;
535
+ if (Array.isArray(converted)) jxChildren.push(...converted);
536
+ else jxChildren.push(converted);
522
537
  }
523
538
  }
524
539
 
@@ -539,12 +554,14 @@ function directiveToJx(node) {
539
554
  /**
540
555
  * Convert an array of mdast children to Jx elements/strings.
541
556
  *
542
- * @param {any[]} children
543
- * @returns {any[]}
557
+ * @param {MdastNode[]} children
558
+ * @returns {(JxElement | string)[]}
544
559
  */
545
560
  export function convertChildren(children) {
546
561
  if (!children) return [];
547
- return children.flatMap(mdastNodeToJx).filter((c) => c != null);
562
+ return /** @type {(JxElement | string)[]} */ (
563
+ children.flatMap(mdastNodeToJx).filter((c) => c != null)
564
+ );
548
565
  }
549
566
 
550
567
  /**
@@ -568,20 +585,25 @@ export function transpileJxMarkdown(source) {
568
585
  const vfile = { data: {} };
569
586
  processor.runSync(tree, vfile);
570
587
 
571
- const frontmatter = /** @type {any} */ (vfile.data)?.frontmatter ?? {};
588
+ const frontmatter = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (vfile.data))
589
+ ?.frontmatter ?? {};
572
590
 
573
- /** @type {Record<string, any>} */
591
+ /** @type {JxDocument} */
574
592
  const doc = {};
575
593
 
576
594
  for (const [key, value] of Object.entries(frontmatter)) {
577
595
  doc[key] = value;
578
596
  }
579
597
 
580
- const bodyNodes = tree.children.filter(
581
- (/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml",
598
+ const bodyNodes = /** @type {MdastNode[]} */ (
599
+ /** @type {unknown} */ (
600
+ tree.children.filter(
601
+ (/** @type {{ type: string }} */ n) => n.type !== "yaml" && n.type !== "toml",
602
+ )
603
+ )
582
604
  );
583
605
 
584
- /** @type {any[]} */
606
+ /** @type {(JxElement | string)[]} */
585
607
  const children = [];
586
608
 
587
609
  for (const node of bodyNodes) {