@theseus.run/jsx-md 0.1.1 → 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.
- package/README.md +72 -204
- package/dist/index-8tdwjkh9.js +11 -0
- package/dist/index-8tdwjkh9.js.map +10 -0
- package/dist/index.js +435 -0
- package/dist/index.js.map +13 -0
- package/dist/jsx-dev-runtime.js +13 -0
- package/dist/jsx-dev-runtime.js.map +9 -0
- package/dist/jsx-runtime.js +13 -0
- package/dist/jsx-runtime.js.map +9 -0
- package/package.json +25 -9
- package/src/context.ts +47 -4
- package/src/escape.ts +69 -11
- package/src/index.ts +5 -1
- package/src/jsx-runtime.ts +41 -26
- package/src/primitives.tsx +140 -74
- package/src/render.ts +54 -19
- package/src/_render-registry.ts +0 -16
package/src/primitives.tsx
CHANGED
|
@@ -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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
//
|
|
88
|
+
// OlCollectorContext — collects Li items for Ol numbering
|
|
87
89
|
// ---------------------------------------------------------------------------
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
|
-
*
|
|
91
|
-
* Li
|
|
92
|
-
*
|
|
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
|
-
|
|
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
|
|
135
|
+
return `# ${render(children).trim()}\n\n`;
|
|
106
136
|
}
|
|
107
137
|
|
|
108
138
|
export function H2({ children }: BlockProps): string {
|
|
109
|
-
return `## ${render(children
|
|
139
|
+
return `## ${render(children).trim()}\n\n`;
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
export function H3({ children }: BlockProps): string {
|
|
113
|
-
return `### ${render(children
|
|
143
|
+
return `### ${render(children).trim()}\n\n`;
|
|
114
144
|
}
|
|
115
145
|
|
|
116
146
|
export function H4({ children }: BlockProps): string {
|
|
117
|
-
return `#### ${render(children
|
|
147
|
+
return `#### ${render(children).trim()}\n\n`;
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
export function H5({ children }: BlockProps): string {
|
|
121
|
-
return `##### ${render(children
|
|
151
|
+
return `##### ${render(children).trim()}\n\n`;
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
export function H6({ children }: BlockProps): string {
|
|
125
|
-
return `###### ${render(children
|
|
155
|
+
return `###### ${render(children).trim()}\n\n`;
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
export function P({ children }: BlockProps): string {
|
|
129
|
-
return `${render(children
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
181
|
-
|
|
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
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
|
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
|
|
216
|
-
if (
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
275
|
+
return ` ${render(children)} |`;
|
|
238
276
|
}
|
|
239
277
|
|
|
240
278
|
export function Tr({ children }: { children?: VNode }): string {
|
|
241
|
-
return `|${render(children
|
|
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
|
|
247
|
-
*
|
|
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
|
|
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
|
-
|
|
309
|
+
const inner = render(children).replace(/\*\*/g, '\\*\\*');
|
|
310
|
+
return `**${inner}**`;
|
|
272
311
|
}
|
|
273
312
|
|
|
274
313
|
export function Code({ children }: InlineProps): string {
|
|
275
|
-
|
|
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
|
|
320
|
+
return `*${render(children)}*`;
|
|
280
321
|
}
|
|
281
322
|
|
|
282
323
|
export function Strikethrough({ children }: InlineProps): string {
|
|
283
|
-
|
|
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
|
|
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
|
|
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
|
|
319
|
-
// Mirror Ul: trailing \n
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
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 `<!-- ${
|
|
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
|
|
382
|
-
return `<details>\n<summary>${
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 `<${
|
|
87
|
+
if (inner.trimEnd() === '') {
|
|
88
|
+
return `<${tagName}${attrStr} />\n`;
|
|
49
89
|
}
|
|
50
|
-
return `<${
|
|
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
|
|
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);
|
package/src/_render-registry.ts
DELETED
|
@@ -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
|
-
}
|