prev-cli 0.24.16 → 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.
- package/dist/cli.js +2 -1
- package/package.json +5 -2
- package/src/jsx/adapters/html.test.ts +191 -0
- package/src/jsx/adapters/html.ts +404 -0
- package/src/jsx/adapters/react.test.ts +172 -0
- package/src/jsx/adapters/react.tsx +346 -0
- package/src/jsx/define-component.ts +129 -0
- package/src/jsx/index.ts +47 -0
- package/src/jsx/jsx-runtime.test.ts +63 -0
- package/src/jsx/jsx-runtime.ts +117 -0
- package/src/jsx/migrate.test.ts +72 -0
- package/src/jsx/migrate.ts +451 -0
- package/src/jsx/schemas/index.ts +4 -0
- package/src/jsx/schemas/primitives.ts +107 -0
- package/src/jsx/schemas/tokens.ts +60 -0
- package/src/jsx/validation.ts +77 -0
- package/src/jsx/vnode.ts +159 -0
- package/src/primitives/index.ts +8 -0
- package/src/primitives/migrate.test.ts +317 -0
- package/src/primitives/migrate.ts +364 -0
- package/src/primitives/parser.test.ts +265 -0
- package/src/primitives/parser.ts +442 -0
- package/src/primitives/template-parser.test.ts +297 -0
- package/src/primitives/template-parser.ts +374 -0
- package/src/primitives/template-renderer.test.ts +359 -0
- package/src/primitives/template-renderer.ts +497 -0
- package/src/primitives/tokens.css +82 -0
- package/src/primitives/types.ts +248 -0
- package/src/tokens/defaults.test.ts +137 -0
- package/src/tokens/defaults.ts +77 -0
- package/src/tokens/defaults.yaml +76 -0
- package/src/tokens/resolver.test.ts +229 -0
- package/src/tokens/resolver.ts +173 -0
- package/src/tokens/utils.test.ts +172 -0
- package/src/tokens/utils.ts +104 -0
- package/src/tokens/validation.test.ts +118 -0
- package/src/tokens/validation.ts +226 -0
package/dist/cli.js
CHANGED
|
@@ -2999,6 +2999,7 @@ async function createViteConfig(options) {
|
|
|
2999
2999
|
format: "esm",
|
|
3000
3000
|
jsx: "automatic",
|
|
3001
3001
|
jsxImportSource: "react",
|
|
3002
|
+
jsxDev: false,
|
|
3002
3003
|
target: "es2020",
|
|
3003
3004
|
minify: false,
|
|
3004
3005
|
sourcemap: false,
|
|
@@ -3010,7 +3011,7 @@ async function createViteConfig(options) {
|
|
|
3010
3011
|
"react/jsx-runtime": "https://esm.sh/react@18/jsx-runtime"
|
|
3011
3012
|
},
|
|
3012
3013
|
define: {
|
|
3013
|
-
"process.env.NODE_ENV": '"
|
|
3014
|
+
"process.env.NODE_ENV": '"production"'
|
|
3014
3015
|
}
|
|
3015
3016
|
});
|
|
3016
3017
|
const bundleTime = Math.round(performance.now() - startTime);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prev-cli",
|
|
3
|
-
"version": "0.24.
|
|
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('<script>')
|
|
24
|
+
expect(renderToHtml(Icon({ name: 'star', color: 'primary' }))).toContain('text-primary')
|
|
25
|
+
expect(renderToHtml(Image({ src: '"><x>', fit: 'cover' }))).toContain('">')
|
|
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, '&')
|
|
400
|
+
.replace(/</g, '<')
|
|
401
|
+
.replace(/>/g, '>')
|
|
402
|
+
.replace(/"/g, '"')
|
|
403
|
+
.replace(/'/g, ''')
|
|
404
|
+
}
|