@theseus.run/jsx-md 0.1.2 → 0.2.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.
@@ -54,7 +54,7 @@
54
54
  * // Not legitimate — decompose instead
55
55
  * <Li><Md>{'**P1**: Guard clauses over nesting.'}</Md></Li>
56
56
  *
57
- * 4. Nested lists are supported via nested Ul inside Li children.
57
+ * 4. Nested lists are fully supported via nested Ul or Ol inside Li children.
58
58
  * DepthContext tracks the nesting level and computes indentation automatically.
59
59
  *
60
60
  * <Ul>
@@ -63,9 +63,11 @@
63
63
  * </Li>
64
64
  * </Ul>
65
65
  *
66
- * 5. Ol must be at document root (depth 0). Nesting Ol anywhere inside a Ul —
67
- * including inside a Li — throws at runtime because depth > 0.
68
- * Ol is for flat, top-level numbered sequences only.
66
+ * <Ol>
67
+ * <Li>step one
68
+ * <Ol><Li>sub-step a</Li><Li>sub-step b</Li></Ol>
69
+ * </Li>
70
+ * </Ol>
69
71
  */
70
72
 
71
73
  /* @jsxImportSource @theseus.run/jsx-md */
@@ -73,7 +75,7 @@
73
75
  import type { VNode } from './jsx-runtime.ts';
74
76
  import { render } from './render.ts';
75
77
  import { createContext, useContext, withContext } from './context.ts';
76
- import { escapeHtmlContent, encodeLinkUrl, encodeLinkLabel } from './escape.ts';
78
+ import { escapeHtmlContent, encodeLinkUrl, encodeLinkLabel, backtickFenceLength, escapeMarkdown } from './escape.ts';
77
79
 
78
80
  // ---------------------------------------------------------------------------
79
81
  // DepthContext — tracks list nesting level for Li indentation
@@ -83,15 +85,43 @@ import { escapeHtmlContent, encodeLinkUrl, encodeLinkLabel } from './escape.ts';
83
85
  const DepthContext = createContext(0);
84
86
 
85
87
  // ---------------------------------------------------------------------------
86
- // OlContextsignals that Li is inside an Ol (uses sentinel marker)
88
+ // OlCollectorContextcollects Li items for Ol numbering
87
89
  // ---------------------------------------------------------------------------
88
90
 
89
91
  /**
90
- * Signals that the current Li is being rendered inside an Ol.
91
- * Li emits a sentinel prefix (\x01) instead of "- " when this is true,
92
- * preventing Ol's post-processor from matching literal "- " content.
92
+ * Mutable item-collector box passed through context during Ol rendering.
93
+ * Li pushes its rendered content into collector.items (and returns '') when
94
+ * an OlCollector is present; Ol reads the items after rendering to number them.
95
+ * This eliminates the sentinel-character post-processing hack and allows Ol
96
+ * to be nested at any depth, just like Ul.
93
97
  */
94
- const OlContext = createContext(false);
98
+ type OlCollector = { items: string[] };
99
+ const OlCollectorContext = createContext<OlCollector | null>(null);
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // ColSpecContext — tracks Th alignment per column for Table separator row
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Alignment value for a GFM table column.
107
+ * 'left' → `:---`, 'center' → `:---:`, 'right' → `---:`, undefined → `---`.
108
+ */
109
+ export type ColAlign = 'left' | 'center' | 'right';
110
+
111
+ /**
112
+ * Mutable spec box passed through context during Table rendering.
113
+ * Th pushes its alignment (or undefined) into spec.cols; Table reads the
114
+ * array after the header row renders to build the GFM separator row.
115
+ */
116
+ type ColSpec = { cols: Array<ColAlign | undefined> };
117
+ const ColSpecContext = createContext<ColSpec | null>(null);
118
+
119
+ function alignSeparator(align: ColAlign | undefined): string {
120
+ if (align === 'left') return ':---';
121
+ if (align === 'center') return ':---:';
122
+ if (align === 'right') return '---:';
123
+ return '---';
124
+ }
95
125
 
96
126
  // ---------------------------------------------------------------------------
97
127
  // Block elements — trailing \n\n
@@ -102,34 +132,34 @@ interface BlockProps {
102
132
  }
103
133
 
104
134
  export function H1({ children }: BlockProps): string {
105
- return `# ${render(children ?? null)}\n\n`;
135
+ return `# ${render(children).trim()}\n\n`;
106
136
  }
107
137
 
108
138
  export function H2({ children }: BlockProps): string {
109
- return `## ${render(children ?? null)}\n\n`;
139
+ return `## ${render(children).trim()}\n\n`;
110
140
  }
111
141
 
112
142
  export function H3({ children }: BlockProps): string {
113
- return `### ${render(children ?? null)}\n\n`;
143
+ return `### ${render(children).trim()}\n\n`;
114
144
  }
115
145
 
116
146
  export function H4({ children }: BlockProps): string {
117
- return `#### ${render(children ?? null)}\n\n`;
147
+ return `#### ${render(children).trim()}\n\n`;
118
148
  }
119
149
 
120
150
  export function H5({ children }: BlockProps): string {
121
- return `##### ${render(children ?? null)}\n\n`;
151
+ return `##### ${render(children).trim()}\n\n`;
122
152
  }
123
153
 
124
154
  export function H6({ children }: BlockProps): string {
125
- return `###### ${render(children ?? null)}\n\n`;
155
+ return `###### ${render(children).trim()}\n\n`;
126
156
  }
127
157
 
128
158
  export function P({ children }: BlockProps): string {
129
- return `${render(children ?? null)}\n\n`;
159
+ return `${render(children)}\n\n`;
130
160
  }
131
161
 
132
- export function Hr(): string {
162
+ export function Hr(_: { children?: never } = {}): string {
133
163
  return '---\n\n';
134
164
  }
135
165
 
@@ -141,14 +171,16 @@ interface CodeblockProps {
141
171
 
142
172
  export function Codeblock({ lang = '', children, indent = 0 }: CodeblockProps): string {
143
173
  const prefix = ' '.repeat(indent);
144
- const rawLines = render(children ?? null).split('\n');
174
+ const content = render(children);
175
+ const fence = '`'.repeat(backtickFenceLength(content, 3));
176
+ const rawLines = content.split('\n');
145
177
  // Drop trailing empty entries produced by a trailing \n in content to prevent
146
178
  // a spurious indented blank line appearing before the closing fence.
147
179
  while (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
148
180
  rawLines.pop();
149
181
  }
150
182
  const body = rawLines.map((line) => prefix + line).join('\n');
151
- return `\`\`\`${lang}\n${body}\n\`\`\`\n\n`;
183
+ return `${fence}${lang}\n${body}\n${fence}\n\n`;
152
184
  }
153
185
 
154
186
  /**
@@ -162,7 +194,7 @@ export function Codeblock({ lang = '', children, indent = 0 }: CodeblockProps):
162
194
  * with `> `, producing `> > text`.
163
195
  */
164
196
  export function Blockquote({ children }: BlockProps): string {
165
- const content = render(children ?? null).trimEnd();
197
+ const content = render(children).trimEnd();
166
198
  const lines = content.split('\n').map((line) => (line === '' ? '>' : `> ${line}`)).join('\n');
167
199
  return `${lines}\n\n`;
168
200
  }
@@ -173,49 +205,50 @@ export function Blockquote({ children }: BlockProps): string {
173
205
 
174
206
  export function Ul({ children }: BlockProps): string {
175
207
  const depth = useContext(DepthContext);
176
- // Reset OlContext so Li items inside a Ul nested within Ol emit "- " not the Ol sentinel
177
- const rendered = withContext(OlContext, false, () =>
178
- withContext(DepthContext, depth + 1, () => render(children ?? null)),
208
+ // Clear OlCollectorContext so Li items inside a Ul nested within Ol
209
+ // render as "- " bullet items, not push to the outer Ol's collector.
210
+ const rendered = withContext(OlCollectorContext, null, () =>
211
+ withContext(DepthContext, depth + 1, () => render(children)),
179
212
  );
180
- // Add trailing newline only at the outermost list level
181
- return depth === 0 ? `${rendered}\n` : rendered;
213
+ // At depth 0 (outermost): trailing \n to form a block.
214
+ // At depth > 0 (nested): leading \n so the sublist starts on its own line
215
+ // when concatenated with the parent Li text.
216
+ return depth === 0 ? `${rendered}\n` : `\n${rendered}`;
182
217
  }
183
218
 
184
219
  /**
185
220
  * Ordered list — auto-numbers Li children at the current depth level.
186
221
  *
187
222
  * Ul/Ol increment the depth before rendering children, so Li items know
188
- * their indentation. When OlContext is true, Li emits a sentinel prefix
189
- * (\x01) instead of "- ", which Ol replaces with numbered prefixes. This
190
- * prevents false matches on Li content that literally begins with "- ".
191
- *
192
- * Constraint: Ol must be at document root (depth 0). Nesting Ol anywhere
193
- * inside a Ul — including inside a Li — throws at runtime because depth > 0.
194
- * Ol is for flat, top-level numbered sequences only.
223
+ * their indentation. When an OlCollector is present in context, Li pushes
224
+ * its content to collector.items and returns '' Ol then numbers the
225
+ * collected items. This pattern supports Ol at any nesting depth, including
226
+ * Ol inside Ol, Ol inside Ul, etc.
195
227
  */
196
228
  export function Ol({ children }: BlockProps): string {
197
229
  const depth = useContext(DepthContext);
198
- if (depth > 0) {
199
- throw new Error('Ol cannot be used inside any list container (Ul, Ol, or TaskList) — depth must be 0.');
200
- }
201
- const MARKER = '\x01';
202
- const rendered = withContext(OlContext, true, () =>
203
- withContext(DepthContext, depth + 1, () => render(children ?? null)),
230
+ const collector: OlCollector = { items: [] };
231
+ withContext(OlCollectorContext, collector, () =>
232
+ withContext(DepthContext, depth + 1, () => render(children)),
204
233
  );
205
- let counter = 0;
206
- return rendered.replace(/^\x01/gm, () => `${++counter}. `) + '\n';
234
+ const indent = ' '.repeat(depth);
235
+ const numbered = collector.items
236
+ .map((item, i) => `${indent}${i + 1}. ${item}`)
237
+ .join('');
238
+ return depth === 0 ? `${numbered}\n` : `\n${numbered}`;
207
239
  }
208
240
 
209
241
  export function Li({ children }: BlockProps): string {
210
242
  const depth = useContext(DepthContext);
211
- const isInOl = useContext(OlContext);
243
+ const collector = useContext(OlCollectorContext);
212
244
  // depth is already incremented by the enclosing Ul/Ol, so depth 1 = top-level.
213
245
  // Math.max guard: safe when Li is used outside Ul/Ol (depth=0).
214
246
  const indent = ' '.repeat(Math.max(0, depth - 1));
215
- const inner = render(children ?? null).trimEnd();
216
- if (isInOl) {
217
- // \x01 is the sentinel Ol replaces with a number prevents "- " content from being matched
218
- return `${indent}\x01${inner}\n`;
247
+ const inner = render(children).trimEnd();
248
+ if (collector) {
249
+ // Inside Ol: push content to collector; Ol will number items after rendering.
250
+ collector.items.push(`${inner}\n`);
251
+ return '';
219
252
  }
220
253
  return `${indent}- ${inner}\n`;
221
254
  }
@@ -228,33 +261,38 @@ export function Li({ children }: BlockProps): string {
228
261
  * Th and Td are semantically identical in GFM — position determines header
229
262
  * styling, not cell type. Both return ` content |` (trailing pipe delimiter).
230
263
  * Tr prepends the leading `|` to form a complete row line.
264
+ *
265
+ * Th also increments the ColCountContext counter so Table knows how many
266
+ * columns the header row has without parsing pipe characters.
231
267
  */
232
- export function Th({ children }: { children?: VNode }): string {
233
- return ` ${render(children ?? null)} |`;
268
+ export function Th({ children, align }: { children?: VNode; align?: ColAlign }): string {
269
+ const spec = useContext(ColSpecContext);
270
+ if (spec) spec.cols.push(align);
271
+ return ` ${render(children)} |`;
234
272
  }
235
273
 
236
274
  export function Td({ children }: { children?: VNode }): string {
237
- return ` ${render(children ?? null)} |`;
275
+ return ` ${render(children)} |`;
238
276
  }
239
277
 
240
278
  export function Tr({ children }: { children?: VNode }): string {
241
- return `|${render(children ?? null)}\n`;
279
+ return `|${render(children)}\n`;
242
280
  }
243
281
 
244
282
  /**
245
283
  * Table — renders Tr children, then injects a GFM separator row after the
246
- * first row (the header). Column count is derived from the first row's pipe
247
- * count so no column metadata needs to be threaded through context.
284
+ * first row (the header). Column count is obtained from ColCountContext which
285
+ * Th increments during rendering correct even when cells contain '|'.
248
286
  */
249
287
  export function Table({ children }: { children?: VNode }): string {
250
- const rendered = render(children ?? null);
288
+ const spec: ColSpec = { cols: [] };
289
+ const rendered = withContext(ColSpecContext, spec, () => render(children));
251
290
  const lines = rendered.split('\n').filter((l) => l.trim().length > 0);
252
291
  if (lines.length === 0) {
253
292
  return '';
254
293
  }
294
+ const separator = '| ' + spec.cols.map(alignSeparator).join(' | ') + ' |';
255
295
  const headerLine = lines[0]!;
256
- const colCount = headerLine.split('|').filter((s) => s.trim().length > 0).length;
257
- const separator = '| ' + Array(colCount).fill('---').join(' | ') + ' |';
258
296
  const bodyLines = lines.slice(1);
259
297
  return [headerLine, separator, ...bodyLines].join('\n') + '\n\n';
260
298
  }
@@ -268,19 +306,43 @@ interface InlineProps {
268
306
  }
269
307
 
270
308
  export function Bold({ children }: InlineProps): string {
271
- return `**${render(children ?? null)}**`;
309
+ const inner = render(children).replace(/\*\*/g, '\\*\\*');
310
+ return `**${inner}**`;
272
311
  }
273
312
 
274
313
  export function Code({ children }: InlineProps): string {
275
- return `\`${render(children ?? null)}\``;
314
+ const inner = render(children);
315
+ const fence = '`'.repeat(backtickFenceLength(inner));
316
+ return `${fence}${inner}${fence}`;
276
317
  }
277
318
 
278
319
  export function Italic({ children }: InlineProps): string {
279
- return `*${render(children ?? null)}*`;
320
+ return `*${render(children)}*`;
280
321
  }
281
322
 
282
323
  export function Strikethrough({ children }: InlineProps): string {
283
- return `~~${render(children ?? null)}~~`;
324
+ const inner = render(children).replace(/~~/g, '\\~\\~');
325
+ return `~~${inner}~~`;
326
+ }
327
+
328
+ export function Br(_: { children?: never } = {}): string {
329
+ return ' \n';
330
+ }
331
+
332
+ export function Sup({ children }: InlineProps): string {
333
+ return `<sup>${render(children)}</sup>`;
334
+ }
335
+
336
+ export function Sub({ children }: InlineProps): string {
337
+ return `<sub>${render(children)}</sub>`;
338
+ }
339
+
340
+ export function Kbd({ children }: InlineProps): string {
341
+ return `<kbd>${render(children)}</kbd>`;
342
+ }
343
+
344
+ export function Escape({ children }: InlineProps): string {
345
+ return escapeMarkdown(render(children));
284
346
  }
285
347
 
286
348
  interface LinkProps {
@@ -289,7 +351,7 @@ interface LinkProps {
289
351
  }
290
352
 
291
353
  export function Link({ href, children }: LinkProps): string {
292
- return `[${render(children ?? null)}](${encodeLinkUrl(href)})`;
354
+ return `[${render(children)}](${encodeLinkUrl(href)})`;
293
355
  }
294
356
 
295
357
  interface ImgProps {
@@ -306,7 +368,7 @@ export function Img({ src, alt = '' }: ImgProps): string {
306
368
  // ---------------------------------------------------------------------------
307
369
 
308
370
  export function Md({ children }: BlockProps): string {
309
- return render(children ?? null);
371
+ return render(children);
310
372
  }
311
373
 
312
374
  // ---------------------------------------------------------------------------
@@ -315,9 +377,9 @@ export function Md({ children }: BlockProps): string {
315
377
 
316
378
  export function TaskList({ children }: { children?: VNode }): string {
317
379
  const depth = useContext(DepthContext);
318
- const rendered = withContext(DepthContext, depth + 1, () => render(children ?? null));
319
- // Mirror Ul: trailing \n only at outermost task list level
320
- return depth === 0 ? `${rendered}\n` : rendered;
380
+ const rendered = withContext(DepthContext, depth + 1, () => render(children));
381
+ // Mirror Ul: at depth 0 trailing \n, at depth > 0 leading \n
382
+ return depth === 0 ? `${rendered}\n` : `\n${rendered}`;
321
383
  }
322
384
 
323
385
  export function Task({ children, done }: { children?: VNode; done?: boolean }): string {
@@ -325,9 +387,7 @@ export function Task({ children, done }: { children?: VNode; done?: boolean }):
325
387
  // Math.max guard matches Li's defensive pattern — safe when Task is used outside TaskList (depth=0)
326
388
  const indent = ' '.repeat(Math.max(0, depth - 1));
327
389
  const prefix = done ? '[x]' : '[ ]';
328
- // NOTE: nested TaskList inside a Task appends first inner item inline (same known
329
- // limitation as Li with nested Ul — structural fix requires block-aware rendering)
330
- const inner = render(children ?? null).trimEnd();
390
+ const inner = render(children).trimEnd();
331
391
  return `${indent}- ${prefix} ${inner}\n`;
332
392
  }
333
393
 
@@ -344,7 +404,7 @@ export function Callout({
344
404
  children?: VNode;
345
405
  type: CalloutType;
346
406
  }): string {
347
- const inner = render(children ?? null).trimEnd();
407
+ const inner = render(children).trimEnd();
348
408
  const lines = inner.split('\n').map((line) => (line === '' ? '>' : `> ${line}`)).join('\n');
349
409
  return `> [!${type.toUpperCase()}]\n${lines}\n\n`;
350
410
  }
@@ -354,15 +414,19 @@ export function Callout({
354
414
  // ---------------------------------------------------------------------------
355
415
 
356
416
  export function HtmlComment({ children }: { children?: VNode }): string {
357
- const inner = render(children ?? null).trimEnd();
417
+ const inner = render(children).trimEnd();
358
418
  // Use .trim() only for the empty-check: whitespace-only content → <!-- -->
359
419
  if (!inner.trim()) {
360
420
  return `<!-- -->\n`;
361
421
  }
362
- if (inner.includes('\n')) {
363
- return `<!--\n${inner}\n-->\n`;
422
+ // Sanitize in a single pass (left-to-right alternation):
423
+ // '-->': closes comment prematurely → replace '>' with ' >' to break the sequence
424
+ // '--': invalid per HTML spec → replace second '-' with ' -'
425
+ const safe = inner.replace(/-->|--/g, (m) => (m === '-->' ? '-- >' : '- -'));
426
+ if (safe.includes('\n')) {
427
+ return `<!--\n${safe}\n-->\n`;
364
428
  }
365
- return `<!-- ${inner} -->\n`;
429
+ return `<!-- ${safe} -->\n`;
366
430
  }
367
431
 
368
432
  // ---------------------------------------------------------------------------
@@ -376,8 +440,10 @@ export function Details({
376
440
  children?: VNode;
377
441
  summary: string;
378
442
  }): string {
443
+ // Newlines in summary break the <summary> element — collapse to spaces.
444
+ const safeSummary = escapeHtmlContent(summary.replace(/\n/g, ' '));
379
445
  // trimEnd() required: GitHub needs a blank line before </details> to render body as markdown.
380
446
  // The \n\n in the template provides that; trimEnd prevents double-blank-lines.
381
- const body = render(children ?? null).trimEnd();
382
- return `<details>\n<summary>${escapeHtmlContent(summary)}</summary>\n\n${body}\n\n</details>\n`;
447
+ const body = render(children).trimEnd();
448
+ return `<details>\n<summary>${safeSummary}</summary>\n\n${body}\n\n</details>\n`;
383
449
  }
package/src/render.ts CHANGED
@@ -5,13 +5,13 @@
5
5
  * here, not at JSX construction time. Children are passed as raw VNode values
6
6
  * to each component so they can wrap rendering in context (e.g. DepthContext).
7
7
  *
8
- * Registers itself with the render registry (via _render-registry.ts) on module
9
- * init to provide Fragment with a render reference without a circular import.
8
+ * Fragment is a Symbol (imported from jsx-runtime.ts). When render() encounters
9
+ * a VNodeElement whose type is the Fragment symbol, it renders children directly.
10
+ * This keeps the dependency one-way (render.ts → jsx-runtime.ts) with no cycle.
10
11
  */
11
12
 
12
- import { registerRender } from './_render-registry.ts';
13
13
  import { escapeHtmlAttr } from './escape.ts';
14
- import { type VNode, type VNodeElement } from './jsx-runtime.ts';
14
+ import { Fragment, type VNode, type VNodeElement } from './jsx-runtime.ts';
15
15
 
16
16
  function isVNodeElement(node: VNode): node is VNodeElement {
17
17
  return typeof node === 'object' && node !== null && !Array.isArray(node);
@@ -25,37 +25,72 @@ export function render(node: VNode): string {
25
25
  return node;
26
26
  }
27
27
  if (typeof node === 'number') {
28
- return String(node);
28
+ return Number.isFinite(node) ? String(node) : '';
29
29
  }
30
30
  if (Array.isArray(node)) {
31
- return (node as ReadonlyArray<VNode>).map(render).join('');
31
+ return node.map(render).join('');
32
32
  }
33
33
  // VNodeElement — dispatch on type
34
34
  if (!isVNodeElement(node)) {
35
+ // From TypeScript's perspective this branch is unreachable: after the null/boolean/string/
36
+ // number/Array guards above, the only remaining VNode member is VNodeElement, so `node`
37
+ // is already narrowed to VNodeElement and `!isVNodeElement` is statically false.
38
+ // The branch is kept as a runtime-only defensive net: if a caller bypasses TypeScript
39
+ // (plain JS, `as any`, etc.) and passes a function as a child, we throw a diagnostic
40
+ // error instead of silently returning ''. The double-cast (`as unknown as fn`) is
41
+ // required precisely because TS knows this is unreachable.
42
+ if (typeof node === 'function') {
43
+ throw new Error(
44
+ `jsx-md: a function was passed as a VNode child. Did you forget to call it, or wrap it in JSX? ` +
45
+ `Received: ${(node as unknown as (...args: unknown[]) => unknown).name || 'anonymous function'}`,
46
+ );
47
+ }
35
48
  return '';
36
49
  }
37
50
  const el = node; // narrowed to VNodeElement
38
51
 
52
+ // Fragment symbol — render children directly
53
+ if (el.type === Fragment) {
54
+ // props is Record<string, unknown> by design (props type varies per-component and cannot
55
+ // be narrowed at the VNodeElement level). The `as VNode` cast is safe in practice: the
56
+ // only source of props.children values is the JSX compiler and user JSX expressions,
57
+ // both of which TypeScript has already validated as VNode at the call site.
58
+ return render(el.props.children as VNode ?? null);
59
+ }
60
+
39
61
  // String type → render as an XML block tag
40
62
  if (typeof el.type === 'string') {
63
+ const tagName = el.type;
64
+ if (!/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(tagName)) {
65
+ throw new Error(
66
+ `jsx-md: invalid XML tag name "${tagName}". Tag names must start with a letter and contain only letters, digits, ':', '.', '_', or '-'.`,
67
+ );
68
+ }
41
69
  const { children, ...attrs } = el.props;
42
- const attrStr = Object.entries(attrs)
43
- .filter(([, v]) => v !== undefined && v !== null && v !== false)
44
- .map(([k, v]) => (v === true ? ` ${k}` : ` ${k}="${escapeHtmlAttr(String(v))}"`))
45
- .join('');
70
+ let attrStr = '';
71
+ for (const [k, v] of Object.entries(attrs)) {
72
+ if (v === undefined || v === null || v === false) continue;
73
+ if (v === true) {
74
+ attrStr += ` ${k}`;
75
+ } else if (typeof v === 'object') {
76
+ throw new Error(
77
+ `jsx-md: attribute "${k}" received an object value. XML attributes must be strings. ` +
78
+ `Use JSON.stringify() to convert: ${k}={JSON.stringify(v)}`,
79
+ );
80
+ } else {
81
+ attrStr += ` ${k}="${escapeHtmlAttr(String(v))}"`;
82
+ }
83
+ }
84
+ // Same cast rationale as the Fragment branch above: props.children is unknown
85
+ // at the VNodeElement level but is guaranteed VNode by the JSX type system.
46
86
  const inner = render(children as VNode ?? null);
47
- if (inner === '') {
48
- return `<${el.type}${attrStr} />\n`;
87
+ if (inner.trimEnd() === '') {
88
+ return `<${tagName}${attrStr} />\n`;
49
89
  }
50
- return `<${el.type}${attrStr}>\n${inner.trimEnd()}\n</${el.type}>\n`;
90
+ return `<${tagName}${attrStr}>\n${inner.trimEnd()}\n</${tagName}>\n`;
51
91
  }
52
92
 
53
93
  // Function component — call with its props (children still as VNode),
54
- // then recurse in case the component returned a VNode (e.g. a Fragment).
94
+ // then recurse in case the component returned a VNode.
55
95
  return render(el.type(el.props));
56
96
  }
57
-
58
- // Register render with Fragment immediately on module init.
59
- // Any entry point that imports render will trigger this before rendering starts.
60
- // Cast is safe: callRender passes only VNode values to render at runtime.
61
- registerRender(render as (node: unknown) => string);
@@ -1,16 +0,0 @@
1
- type RenderFn = (node: unknown) => string;
2
-
3
- let _render: RenderFn | null = null;
4
-
5
- export function registerRender(fn: RenderFn): void {
6
- _render = fn;
7
- }
8
-
9
- export function callRender(node: unknown): string {
10
- if (!_render) {
11
- throw new Error(
12
- "jsx-md: render not initialized. Ensure 'render' is imported from '@theseus.run/jsx-md' before Fragment is called.",
13
- );
14
- }
15
- return _render(node);
16
- }