prev-cli 0.24.18 → 0.24.19

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 (36) hide show
  1. package/package.json +5 -2
  2. package/src/jsx/adapters/html.test.ts +191 -0
  3. package/src/jsx/adapters/html.ts +404 -0
  4. package/src/jsx/adapters/react.test.ts +172 -0
  5. package/src/jsx/adapters/react.tsx +346 -0
  6. package/src/jsx/define-component.ts +129 -0
  7. package/src/jsx/index.ts +47 -0
  8. package/src/jsx/jsx-runtime.test.ts +63 -0
  9. package/src/jsx/jsx-runtime.ts +117 -0
  10. package/src/jsx/migrate.test.ts +72 -0
  11. package/src/jsx/migrate.ts +451 -0
  12. package/src/jsx/schemas/index.ts +4 -0
  13. package/src/jsx/schemas/primitives.ts +107 -0
  14. package/src/jsx/schemas/tokens.ts +60 -0
  15. package/src/jsx/validation.ts +77 -0
  16. package/src/jsx/vnode.ts +159 -0
  17. package/src/primitives/index.ts +8 -0
  18. package/src/primitives/migrate.test.ts +317 -0
  19. package/src/primitives/migrate.ts +364 -0
  20. package/src/primitives/parser.test.ts +265 -0
  21. package/src/primitives/parser.ts +442 -0
  22. package/src/primitives/template-parser.test.ts +297 -0
  23. package/src/primitives/template-parser.ts +374 -0
  24. package/src/primitives/template-renderer.test.ts +359 -0
  25. package/src/primitives/template-renderer.ts +497 -0
  26. package/src/primitives/tokens.css +82 -0
  27. package/src/primitives/types.ts +248 -0
  28. package/src/tokens/defaults.test.ts +137 -0
  29. package/src/tokens/defaults.ts +77 -0
  30. package/src/tokens/defaults.yaml +76 -0
  31. package/src/tokens/resolver.test.ts +229 -0
  32. package/src/tokens/resolver.ts +173 -0
  33. package/src/tokens/utils.test.ts +172 -0
  34. package/src/tokens/utils.ts +104 -0
  35. package/src/tokens/validation.test.ts +118 -0
  36. package/src/tokens/validation.ts +226 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prev-cli",
3
- "version": "0.24.18",
3
+ "version": "0.24.19",
4
4
  "description": "Transform MDX directories into beautiful documentation websites",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,10 @@
11
11
  "dist",
12
12
  "src/theme",
13
13
  "src/ui",
14
- "src/preview-runtime"
14
+ "src/preview-runtime",
15
+ "src/jsx",
16
+ "src/primitives",
17
+ "src/tokens"
15
18
  ],
16
19
  "keywords": [
17
20
  "documentation",
@@ -0,0 +1,191 @@
1
+ // src/jsx/adapters/html.test.ts
2
+ import { test, expect, describe, beforeEach } from 'bun:test'
3
+ import { resetIdCounter } from '../vnode'
4
+ import { Col, Row, Box, Spacer, Slot, Text, Icon, Image } from '../jsx-runtime'
5
+ import { renderToHtml, setTokensConfig } from './html'
6
+ import { resolveTokens } from '../../tokens/resolver'
7
+
8
+ // Get default tokens once at module load for test stability
9
+ const defaultTokens = resolveTokens({})
10
+
11
+ beforeEach(() => {
12
+ resetIdCounter()
13
+ // Explicitly set default tokens for test isolation
14
+ setTokensConfig(defaultTokens)
15
+ })
16
+
17
+ describe('renderToHtml', () => {
18
+ test('primitives, escaping, slots, nesting', () => {
19
+ expect(renderToHtml(Col({ gap: 'lg' }))).toContain('gap-6')
20
+ expect(renderToHtml(Row({ align: 'between' }))).toContain('justify-between')
21
+ expect(renderToHtml(Box({ bg: 'muted' }))).toContain('bg-muted')
22
+ expect(renderToHtml(Spacer())).toContain('flex-1')
23
+ expect(renderToHtml(Text({ children: '<script>', weight: 'bold' }))).toContain('&lt;script&gt;')
24
+ expect(renderToHtml(Icon({ name: 'star', color: 'primary' }))).toContain('text-primary')
25
+ expect(renderToHtml(Image({ src: '"><x>', fit: 'cover' }))).toContain('&quot;&gt;')
26
+
27
+ const html = renderToHtml(Slot({ name: 'x' }), { state: 'on', slots: { x: { on: Text({ children: 'On' }) } } })
28
+ expect(html).toContain('On')
29
+
30
+ expect(renderToHtml(Col({ children: [Text({ children: 'Hi' })] }))).toContain('data-node-id=')
31
+ })
32
+ })
33
+
34
+ describe('HTML adapter token mapping', () => {
35
+ beforeEach(() => {
36
+ setTokensConfig(defaultTokens)
37
+ })
38
+
39
+ test('Box maps bg token to Tailwind class', () => {
40
+ const node = {
41
+ type: 'box' as const,
42
+ id: 'test-1',
43
+ props: { bg: 'primary' },
44
+ children: []
45
+ }
46
+ const html = renderToHtml(node)
47
+ expect(html).toContain('bg-primary')
48
+ })
49
+
50
+ test('Box maps padding token to Tailwind class', () => {
51
+ const node = {
52
+ type: 'box' as const,
53
+ id: 'test-2',
54
+ props: { padding: 'lg' },
55
+ children: []
56
+ }
57
+ const html = renderToHtml(node)
58
+ expect(html).toContain('p-6')
59
+ })
60
+
61
+ test('Text maps color token to Tailwind class', () => {
62
+ const node = {
63
+ type: 'text' as const,
64
+ id: 'test-3',
65
+ props: { content: 'hi', color: 'muted-foreground' },
66
+ children: []
67
+ }
68
+ const html = renderToHtml(node)
69
+ expect(html).toContain('text-muted-foreground')
70
+ })
71
+
72
+ test('Text maps size token to Tailwind class', () => {
73
+ const node = {
74
+ type: 'text' as const,
75
+ id: 'test-4',
76
+ props: { content: 'hi', size: 'lg' },
77
+ children: []
78
+ }
79
+ const html = renderToHtml(node)
80
+ expect(html).toContain('text-lg')
81
+ })
82
+
83
+ test('Text maps weight token to Tailwind class', () => {
84
+ const node = {
85
+ type: 'text' as const,
86
+ id: 'test-5',
87
+ props: { content: 'hi', weight: 'bold' },
88
+ children: []
89
+ }
90
+ const html = renderToHtml(node)
91
+ expect(html).toContain('font-bold')
92
+ })
93
+
94
+ test('Row maps gap token to Tailwind class', () => {
95
+ const node = {
96
+ type: 'row' as const,
97
+ id: 'test-6',
98
+ props: { gap: 'md' },
99
+ children: []
100
+ }
101
+ const html = renderToHtml(node)
102
+ expect(html).toContain('gap-4')
103
+ })
104
+
105
+ test('Box maps radius token to Tailwind class', () => {
106
+ const node = {
107
+ type: 'box' as const,
108
+ id: 'test-7',
109
+ props: { radius: 'lg' },
110
+ children: []
111
+ }
112
+ const html = renderToHtml(node)
113
+ expect(html).toContain('rounded-lg')
114
+ })
115
+
116
+ test('throws error for missing background token', () => {
117
+ const incompleteTokens = {
118
+ ...defaultTokens,
119
+ backgrounds: { ...defaultTokens.backgrounds }
120
+ }
121
+ delete (incompleteTokens.backgrounds as Record<string, unknown>)['primary']
122
+
123
+ setTokensConfig(incompleteTokens as any)
124
+
125
+ const node = {
126
+ type: 'box' as const,
127
+ id: 'test-missing-bg',
128
+ props: { bg: 'primary' },
129
+ children: []
130
+ }
131
+
132
+ expect(() => renderToHtml(node)).toThrow(/Missing background token: "primary"/)
133
+ })
134
+
135
+ test('throws error for missing spacing token', () => {
136
+ const incompleteTokens = {
137
+ ...defaultTokens,
138
+ spacing: { ...defaultTokens.spacing }
139
+ }
140
+ delete (incompleteTokens.spacing as Record<string, unknown>)['lg']
141
+
142
+ setTokensConfig(incompleteTokens as any)
143
+
144
+ const node = {
145
+ type: 'col' as const,
146
+ id: 'test-missing-spacing',
147
+ props: { gap: 'lg' },
148
+ children: []
149
+ }
150
+
151
+ expect(() => renderToHtml(node)).toThrow(/Missing spacing token: "lg"/)
152
+ })
153
+
154
+ test('throws error for missing color token', () => {
155
+ const incompleteTokens = {
156
+ ...defaultTokens,
157
+ colors: { ...defaultTokens.colors }
158
+ }
159
+ delete (incompleteTokens.colors as Record<string, unknown>)['primary']
160
+
161
+ setTokensConfig(incompleteTokens as any)
162
+
163
+ const node = {
164
+ type: 'text' as const,
165
+ id: 'test-missing-color',
166
+ props: { content: 'hi', color: 'primary' },
167
+ children: []
168
+ }
169
+
170
+ expect(() => renderToHtml(node)).toThrow(/Missing color token: "primary"/)
171
+ })
172
+
173
+ test('throws error for missing radius token', () => {
174
+ const incompleteTokens = {
175
+ ...defaultTokens,
176
+ radius: { ...defaultTokens.radius }
177
+ }
178
+ delete (incompleteTokens.radius as Record<string, unknown>)['lg']
179
+
180
+ setTokensConfig(incompleteTokens as any)
181
+
182
+ const node = {
183
+ type: 'box' as const,
184
+ id: 'test-missing-radius',
185
+ props: { radius: 'lg' },
186
+ children: []
187
+ }
188
+
189
+ expect(() => renderToHtml(node)).toThrow(/Missing radius token: "lg"/)
190
+ })
191
+ })
@@ -0,0 +1,404 @@
1
+ // src/jsx/adapters/html.ts
2
+ // HTML adapter - renders VNode tree to HTML with Tailwind classes
3
+ import type { VNodeType } from '../vnode'
4
+ import type {
5
+ SpacingToken,
6
+ AlignToken,
7
+ BackgroundToken,
8
+ RadiusToken,
9
+ ColorToken,
10
+ SizeToken,
11
+ WeightToken,
12
+ FitToken,
13
+ } from '../schemas/tokens'
14
+ import { resolveTokens, type TokensConfig } from '../../tokens/resolver'
15
+
16
+ // ============================================================
17
+ // Token resolution with caching for validation
18
+ // ============================================================
19
+
20
+ // Cached tokens - resolved once and reused
21
+ let cachedTokens: TokensConfig | null = null
22
+
23
+ // NOTE: getTokens() is called multiple times throughout the rendering process.
24
+ // This is acceptable because:
25
+ // - The first call caches the tokens in cachedTokens
26
+ // - Subsequent calls just check `if (!cachedTokens)` which is O(1)
27
+ // - For a CLI preview tool, this micro-optimization is not worth the complexity
28
+ // of threading tokens through every function
29
+ function getTokens(): TokensConfig {
30
+ if (!cachedTokens) {
31
+ cachedTokens = resolveTokens({})
32
+ }
33
+ return cachedTokens
34
+ }
35
+
36
+ // Allow setting custom tokens (for testing and runtime override)
37
+ export function setTokensConfig(tokens: TokensConfig | undefined): void {
38
+ cachedTokens = tokens || null
39
+ }
40
+
41
+ // Validation helpers - check token exists, then let Tailwind handle the actual value
42
+ function validateSpacing(token: SpacingToken): void {
43
+ if (getTokens().spacing[token] === undefined) {
44
+ throw new Error(`Missing spacing token: "${token}". Check your tokens.yaml configuration.`)
45
+ }
46
+ }
47
+
48
+ function validateBackground(token: BackgroundToken): void {
49
+ if (getTokens().backgrounds[token] === undefined) {
50
+ throw new Error(`Missing background token: "${token}". Check your tokens.yaml configuration.`)
51
+ }
52
+ }
53
+
54
+ function validateRadius(token: RadiusToken): void {
55
+ if (getTokens().radius[token] === undefined) {
56
+ throw new Error(`Missing radius token: "${token}". Check your tokens.yaml configuration.`)
57
+ }
58
+ }
59
+
60
+ function validateColor(token: ColorToken): void {
61
+ if (getTokens().colors[token] === undefined) {
62
+ throw new Error(`Missing color token: "${token}". Check your tokens.yaml configuration.`)
63
+ }
64
+ }
65
+
66
+ function validateTypographySize(token: SizeToken): void {
67
+ if (getTokens().typography.sizes[token] === undefined) {
68
+ throw new Error(`Missing typography.sizes token: "${token}". Check your tokens.yaml configuration.`)
69
+ }
70
+ }
71
+
72
+ function validateTypographyWeight(token: WeightToken): void {
73
+ if (getTokens().typography.weights[token] === undefined) {
74
+ throw new Error(`Missing typography.weights token: "${token}". Check your tokens.yaml configuration.`)
75
+ }
76
+ }
77
+
78
+ // ============================================================
79
+ // Token to Tailwind class mappings (shadcn-compatible)
80
+ // ============================================================
81
+
82
+ const SPACING_GAP: Record<SpacingToken, string> = {
83
+ none: 'gap-0',
84
+ xs: 'gap-1',
85
+ sm: 'gap-2',
86
+ md: 'gap-4',
87
+ lg: 'gap-6',
88
+ xl: 'gap-8',
89
+ '2xl': 'gap-12',
90
+ }
91
+
92
+ const SPACING_PADDING: Record<SpacingToken, string> = {
93
+ none: 'p-0',
94
+ xs: 'p-1',
95
+ sm: 'p-2',
96
+ md: 'p-4',
97
+ lg: 'p-6',
98
+ xl: 'p-8',
99
+ '2xl': 'p-12',
100
+ }
101
+
102
+ const SPACING_SIZE: Record<SpacingToken, string> = {
103
+ none: 'w-0 h-0',
104
+ xs: 'w-1 h-1',
105
+ sm: 'w-2 h-2',
106
+ md: 'w-4 h-4',
107
+ lg: 'w-6 h-6',
108
+ xl: 'w-8 h-8',
109
+ '2xl': 'w-12 h-12',
110
+ }
111
+
112
+ const ALIGN_ITEMS: Record<AlignToken, string> = {
113
+ start: 'items-start',
114
+ center: 'items-center',
115
+ end: 'items-end',
116
+ stretch: 'items-stretch',
117
+ between: 'justify-between',
118
+ }
119
+
120
+ const BG_CLASS: Record<BackgroundToken, string> = {
121
+ transparent: 'bg-transparent',
122
+ background: 'bg-background',
123
+ card: 'bg-card',
124
+ primary: 'bg-primary',
125
+ secondary: 'bg-secondary',
126
+ muted: 'bg-muted',
127
+ accent: 'bg-accent',
128
+ destructive: 'bg-destructive',
129
+ input: 'bg-input',
130
+ }
131
+
132
+ const RADIUS_CLASS: Record<RadiusToken, string> = {
133
+ none: 'rounded-none',
134
+ sm: 'rounded-sm',
135
+ md: 'rounded-md',
136
+ lg: 'rounded-lg',
137
+ xl: 'rounded-xl',
138
+ full: 'rounded-full',
139
+ }
140
+
141
+ const COLOR_CLASS: Record<ColorToken, string> = {
142
+ foreground: 'text-foreground',
143
+ 'card-foreground': 'text-card-foreground',
144
+ primary: 'text-primary',
145
+ 'primary-foreground': 'text-primary-foreground',
146
+ secondary: 'text-secondary',
147
+ 'secondary-foreground': 'text-secondary-foreground',
148
+ muted: 'text-muted',
149
+ 'muted-foreground': 'text-muted-foreground',
150
+ accent: 'text-accent',
151
+ 'accent-foreground': 'text-accent-foreground',
152
+ destructive: 'text-destructive',
153
+ 'destructive-foreground': 'text-destructive-foreground',
154
+ border: 'text-border',
155
+ ring: 'text-ring',
156
+ }
157
+
158
+ const SIZE_CLASS: Record<SizeToken, string> = {
159
+ xs: 'text-xs',
160
+ sm: 'text-sm',
161
+ base: 'text-base',
162
+ lg: 'text-lg',
163
+ xl: 'text-xl',
164
+ '2xl': 'text-2xl',
165
+ }
166
+
167
+ const ICON_SIZE_CLASS: Record<SizeToken, string> = {
168
+ xs: 'w-3 h-3',
169
+ sm: 'w-4 h-4',
170
+ base: 'w-5 h-5',
171
+ lg: 'w-6 h-6',
172
+ xl: 'w-7 h-7',
173
+ '2xl': 'w-8 h-8',
174
+ }
175
+
176
+ const WEIGHT_CLASS: Record<WeightToken, string> = {
177
+ normal: 'font-normal',
178
+ medium: 'font-medium',
179
+ semibold: 'font-semibold',
180
+ bold: 'font-bold',
181
+ }
182
+
183
+ const FIT_CLASS: Record<FitToken, string> = {
184
+ cover: 'object-cover',
185
+ contain: 'object-contain',
186
+ fill: 'object-fill',
187
+ }
188
+
189
+ // ============================================================
190
+ // Render context
191
+ // ============================================================
192
+
193
+ export interface RenderContext {
194
+ /** Current state for slot resolution */
195
+ state?: string
196
+ /** Slots mapping: slotName -> stateName -> VNode */
197
+ slots?: Record<string, Record<string, VNodeType>>
198
+ /** Callback to render component nodes */
199
+ renderComponent?: (name: string, props: Record<string, unknown>, children?: VNodeType[]) => string
200
+ }
201
+
202
+ // ============================================================
203
+ // Main render function
204
+ // ============================================================
205
+
206
+ /**
207
+ * Render a VNode tree to HTML
208
+ */
209
+ export function renderToHtml(node: VNodeType, context: RenderContext = {}): string {
210
+ return renderNode(node, context)
211
+ }
212
+
213
+ function renderNode(node: VNodeType, context: RenderContext): string {
214
+ switch (node.type) {
215
+ case 'col':
216
+ return renderCol(node, context)
217
+ case 'row':
218
+ return renderRow(node, context)
219
+ case 'box':
220
+ return renderBox(node, context)
221
+ case 'spacer':
222
+ return renderSpacer(node)
223
+ case 'slot':
224
+ return renderSlot(node, context)
225
+ case 'text':
226
+ return renderText(node)
227
+ case 'icon':
228
+ return renderIcon(node)
229
+ case 'image':
230
+ return renderImage(node)
231
+ case 'component':
232
+ return renderComponent(node, context)
233
+ default:
234
+ return `<!-- unknown type: ${node.type} -->`
235
+ }
236
+ }
237
+
238
+ function renderChildren(children: VNodeType[] | undefined, context: RenderContext): string {
239
+ if (!children || children.length === 0) return ''
240
+ return children.map(child => renderNode(child, context)).join('\n')
241
+ }
242
+
243
+ // ============================================================
244
+ // Primitive renderers
245
+ // ============================================================
246
+
247
+ function renderCol(node: VNodeType, context: RenderContext): string {
248
+ const classes: string[] = ['flex', 'flex-col']
249
+ const { gap, align, padding } = node.props as { gap?: SpacingToken; align?: AlignToken; padding?: SpacingToken }
250
+
251
+ if (gap) {
252
+ validateSpacing(gap)
253
+ classes.push(SPACING_GAP[gap])
254
+ }
255
+ if (align) classes.push(ALIGN_ITEMS[align])
256
+ if (padding) {
257
+ validateSpacing(padding)
258
+ classes.push(SPACING_PADDING[padding])
259
+ }
260
+
261
+ const childrenHtml = renderChildren(node.children, context)
262
+ return `<div data-primitive="col" data-node-id="${node.id}" class="${classes.join(' ')}">${childrenHtml}</div>`
263
+ }
264
+
265
+ function renderRow(node: VNodeType, context: RenderContext): string {
266
+ const classes: string[] = ['flex', 'flex-row']
267
+ const { gap, align, padding } = node.props as { gap?: SpacingToken; align?: AlignToken; padding?: SpacingToken }
268
+
269
+ if (gap) {
270
+ validateSpacing(gap)
271
+ classes.push(SPACING_GAP[gap])
272
+ }
273
+ // Row defaults to items-center
274
+ classes.push(align ? ALIGN_ITEMS[align] : 'items-center')
275
+ if (padding) {
276
+ validateSpacing(padding)
277
+ classes.push(SPACING_PADDING[padding])
278
+ }
279
+
280
+ const childrenHtml = renderChildren(node.children, context)
281
+ return `<div data-primitive="row" data-node-id="${node.id}" class="${classes.join(' ')}">${childrenHtml}</div>`
282
+ }
283
+
284
+ function renderBox(node: VNodeType, context: RenderContext): string {
285
+ const classes: string[] = []
286
+ const { padding, bg, radius } = node.props as { padding?: SpacingToken; bg?: BackgroundToken; radius?: RadiusToken }
287
+
288
+ if (padding) {
289
+ validateSpacing(padding)
290
+ classes.push(SPACING_PADDING[padding])
291
+ }
292
+ if (bg) {
293
+ validateBackground(bg)
294
+ classes.push(BG_CLASS[bg])
295
+ }
296
+ if (radius) {
297
+ validateRadius(radius)
298
+ classes.push(RADIUS_CLASS[radius])
299
+ }
300
+
301
+ const childrenHtml = renderChildren(node.children, context)
302
+ return `<div data-primitive="box" data-node-id="${node.id}" class="${classes.join(' ')}">${childrenHtml}</div>`
303
+ }
304
+
305
+ function renderSpacer(node: VNodeType): string {
306
+ const { size } = node.props as { size?: SpacingToken }
307
+
308
+ if (size) {
309
+ validateSpacing(size)
310
+ return `<div data-primitive="spacer" data-node-id="${node.id}" class="${SPACING_SIZE[size]} shrink-0"></div>`
311
+ }
312
+ return `<div data-primitive="spacer" data-node-id="${node.id}" class="flex-1"></div>`
313
+ }
314
+
315
+ function renderSlot(node: VNodeType, context: RenderContext): string {
316
+ const { name } = node.props as { name: string }
317
+ const state = context.state || 'default'
318
+
319
+ if (context.slots && context.slots[name]) {
320
+ const stateMapping = context.slots[name]
321
+ const content = stateMapping[state] || stateMapping.default
322
+
323
+ if (content) {
324
+ return renderNode(content, context)
325
+ }
326
+ }
327
+
328
+ return `<div data-primitive="slot" data-slot-name="${name}" data-node-id="${node.id}"><!-- slot: ${name} --></div>`
329
+ }
330
+
331
+ function renderText(node: VNodeType): string {
332
+ const classes: string[] = []
333
+ const { content, size, weight, color } = node.props as {
334
+ content?: string
335
+ size?: SizeToken
336
+ weight?: WeightToken
337
+ color?: ColorToken
338
+ }
339
+
340
+ if (size) {
341
+ validateTypographySize(size)
342
+ classes.push(SIZE_CLASS[size])
343
+ }
344
+ if (weight) {
345
+ validateTypographyWeight(weight)
346
+ classes.push(WEIGHT_CLASS[weight])
347
+ }
348
+ if (color) {
349
+ validateColor(color)
350
+ classes.push(COLOR_CLASS[color])
351
+ }
352
+
353
+ return `<span data-primitive="text" data-node-id="${node.id}" class="${classes.join(' ')}">${escapeHtml(content || '')}</span>`
354
+ }
355
+
356
+ function renderIcon(node: VNodeType): string {
357
+ const classes: string[] = ['inline-flex', 'items-center', 'justify-center']
358
+ const { name, size, color } = node.props as { name: string; size?: SizeToken; color?: ColorToken }
359
+
360
+ if (size) {
361
+ validateTypographySize(size)
362
+ classes.push(ICON_SIZE_CLASS[size])
363
+ }
364
+ if (color) {
365
+ validateColor(color)
366
+ classes.push(COLOR_CLASS[color])
367
+ }
368
+
369
+ return `<span data-primitive="icon" data-icon="${escapeHtml(name)}" data-node-id="${node.id}" class="${classes.join(' ')}"><!-- icon: ${escapeHtml(name)} --></span>`
370
+ }
371
+
372
+ function renderImage(node: VNodeType): string {
373
+ const classes: string[] = ['max-w-full']
374
+ const { src, alt, fit } = node.props as { src: string; alt?: string; fit?: FitToken }
375
+
376
+ if (fit) classes.push(FIT_CLASS[fit])
377
+
378
+ return `<img data-primitive="image" data-node-id="${node.id}" src="${escapeHtml(src)}" alt="${escapeHtml(alt || '')}" class="${classes.join(' ')}" />`
379
+ }
380
+
381
+ function renderComponent(node: VNodeType, context: RenderContext): string {
382
+ const componentName = node.componentName || 'Unknown'
383
+
384
+ if (context.renderComponent) {
385
+ return context.renderComponent(componentName, node.props, node.children)
386
+ }
387
+
388
+ // Default: render children wrapped in component marker
389
+ const childrenHtml = renderChildren(node.children, context)
390
+ return `<div data-component="${componentName}" data-node-id="${node.id}">${childrenHtml}</div>`
391
+ }
392
+
393
+ // ============================================================
394
+ // Utilities
395
+ // ============================================================
396
+
397
+ function escapeHtml(text: string): string {
398
+ return text
399
+ .replace(/&/g, '&amp;')
400
+ .replace(/</g, '&lt;')
401
+ .replace(/>/g, '&gt;')
402
+ .replace(/"/g, '&quot;')
403
+ .replace(/'/g, '&#039;')
404
+ }