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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// src/jsx/adapters/react.test.ts
|
|
2
|
+
import { describe, test, expect, beforeEach } from 'bun:test'
|
|
3
|
+
import { toReact, setTokensConfig } from './react'
|
|
4
|
+
import { resolveTokens } from '../../tokens/resolver'
|
|
5
|
+
|
|
6
|
+
// Get default tokens once at module load for test stability
|
|
7
|
+
const defaultTokens = resolveTokens({})
|
|
8
|
+
|
|
9
|
+
describe('React adapter token resolution', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Explicitly set default tokens for test isolation
|
|
12
|
+
// This avoids relying on filesystem state during tests
|
|
13
|
+
setTokensConfig(defaultTokens)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('Box resolves bg token to background value', () => {
|
|
17
|
+
const node = {
|
|
18
|
+
type: 'box' as const,
|
|
19
|
+
id: 'test-1',
|
|
20
|
+
props: { bg: 'primary' },
|
|
21
|
+
children: []
|
|
22
|
+
}
|
|
23
|
+
const element = toReact(node) as React.ReactElement
|
|
24
|
+
expect(element.props.style.background).toBe('#2563eb')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('Box resolves padding token to spacing value', () => {
|
|
28
|
+
const node = {
|
|
29
|
+
type: 'box' as const,
|
|
30
|
+
id: 'test-2',
|
|
31
|
+
props: { padding: 'lg' },
|
|
32
|
+
children: []
|
|
33
|
+
}
|
|
34
|
+
const element = toReact(node) as React.ReactElement
|
|
35
|
+
expect(element.props.style.padding).toBe('24px')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('Text resolves color token', () => {
|
|
39
|
+
const node = {
|
|
40
|
+
type: 'text' as const,
|
|
41
|
+
id: 'test-3',
|
|
42
|
+
props: { content: 'hi', color: 'muted-foreground' },
|
|
43
|
+
children: []
|
|
44
|
+
}
|
|
45
|
+
const element = toReact(node) as React.ReactElement
|
|
46
|
+
expect(element.props.style.color).toBe('#64748b')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('Text resolves size token', () => {
|
|
50
|
+
const node = {
|
|
51
|
+
type: 'text' as const,
|
|
52
|
+
id: 'test-4',
|
|
53
|
+
props: { content: 'hi', size: 'lg' },
|
|
54
|
+
children: []
|
|
55
|
+
}
|
|
56
|
+
const element = toReact(node) as React.ReactElement
|
|
57
|
+
expect(element.props.style.fontSize).toBe('18px')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('Text resolves weight token', () => {
|
|
61
|
+
const node = {
|
|
62
|
+
type: 'text' as const,
|
|
63
|
+
id: 'test-5',
|
|
64
|
+
props: { content: 'hi', weight: 'bold' },
|
|
65
|
+
children: []
|
|
66
|
+
}
|
|
67
|
+
const element = toReact(node) as React.ReactElement
|
|
68
|
+
expect(element.props.style.fontWeight).toBe(700)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('Box resolves radius token', () => {
|
|
72
|
+
const node = {
|
|
73
|
+
type: 'box' as const,
|
|
74
|
+
id: 'test-6',
|
|
75
|
+
props: { radius: 'lg' },
|
|
76
|
+
children: []
|
|
77
|
+
}
|
|
78
|
+
const element = toReact(node) as React.ReactElement
|
|
79
|
+
expect(element.props.style.borderRadius).toBe('8px')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('Row resolves gap token', () => {
|
|
83
|
+
const node = {
|
|
84
|
+
type: 'row' as const,
|
|
85
|
+
id: 'test-7',
|
|
86
|
+
props: { gap: 'md' },
|
|
87
|
+
children: []
|
|
88
|
+
}
|
|
89
|
+
const element = toReact(node) as React.ReactElement
|
|
90
|
+
expect(element.props.style.gap).toBe('16px')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('uses custom tokens when set via setTokensConfig', () => {
|
|
94
|
+
// Import TokensConfig type
|
|
95
|
+
const customTokens = {
|
|
96
|
+
colors: {
|
|
97
|
+
foreground: '#000',
|
|
98
|
+
'card-foreground': '#000',
|
|
99
|
+
'popover-foreground': '#000',
|
|
100
|
+
primary: '#ff0000', // Custom red
|
|
101
|
+
'primary-foreground': '#fff',
|
|
102
|
+
secondary: '#64748b',
|
|
103
|
+
'secondary-foreground': '#000',
|
|
104
|
+
muted: '#94a3b8',
|
|
105
|
+
'muted-foreground': '#64748b',
|
|
106
|
+
accent: '#2563eb',
|
|
107
|
+
'accent-foreground': '#fff',
|
|
108
|
+
destructive: '#ef4444',
|
|
109
|
+
'destructive-foreground': '#fff',
|
|
110
|
+
border: '#e2e8f0',
|
|
111
|
+
ring: '#2563eb',
|
|
112
|
+
},
|
|
113
|
+
backgrounds: {
|
|
114
|
+
transparent: 'transparent',
|
|
115
|
+
background: '#fff',
|
|
116
|
+
card: '#fff',
|
|
117
|
+
popover: '#fff',
|
|
118
|
+
primary: '#ff0000', // Custom red
|
|
119
|
+
secondary: '#f1f5f9',
|
|
120
|
+
muted: '#f1f5f9',
|
|
121
|
+
accent: '#f1f5f9',
|
|
122
|
+
destructive: '#ef4444',
|
|
123
|
+
input: '#fff',
|
|
124
|
+
},
|
|
125
|
+
spacing: {
|
|
126
|
+
none: '0',
|
|
127
|
+
xs: '4px',
|
|
128
|
+
sm: '8px',
|
|
129
|
+
md: '16px',
|
|
130
|
+
lg: '24px',
|
|
131
|
+
xl: '32px',
|
|
132
|
+
'2xl': '48px',
|
|
133
|
+
},
|
|
134
|
+
typography: {
|
|
135
|
+
sizes: { xs: '12px', sm: '14px', base: '16px', lg: '18px', xl: '20px', '2xl': '24px' },
|
|
136
|
+
weights: { normal: 400, medium: 500, semibold: 600, bold: 700 },
|
|
137
|
+
},
|
|
138
|
+
radius: { none: '0', sm: '4px', md: '6px', lg: '8px', xl: '12px', full: '9999px' },
|
|
139
|
+
shadows: { none: 'none', sm: '0 1px 2px', md: '0 4px 6px', lg: '0 10px 15px', xl: '0 20px 25px' },
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setTokensConfig(customTokens)
|
|
143
|
+
|
|
144
|
+
const node = {
|
|
145
|
+
type: 'box' as const,
|
|
146
|
+
id: 'test-8',
|
|
147
|
+
props: { bg: 'primary' },
|
|
148
|
+
children: []
|
|
149
|
+
}
|
|
150
|
+
const element = toReact(node) as React.ReactElement
|
|
151
|
+
expect(element.props.style.background).toBe('#ff0000') // Custom red
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('throws error for missing token in custom config', () => {
|
|
155
|
+
const incompleteTokens = {
|
|
156
|
+
...defaultTokens,
|
|
157
|
+
spacing: { ...defaultTokens.spacing }
|
|
158
|
+
}
|
|
159
|
+
delete (incompleteTokens.spacing as Record<string, unknown>)['lg'] // Remove lg
|
|
160
|
+
|
|
161
|
+
setTokensConfig(incompleteTokens as any)
|
|
162
|
+
|
|
163
|
+
const node = {
|
|
164
|
+
type: 'box' as const,
|
|
165
|
+
id: 'test-missing',
|
|
166
|
+
props: { padding: 'lg' },
|
|
167
|
+
children: []
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
expect(() => toReact(node)).toThrow(/Missing spacing token: "lg"/)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// src/jsx/adapters/react.tsx
|
|
2
|
+
// React adapter - renders VNode tree to React elements
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import type { VNodeType } from '../vnode'
|
|
5
|
+
import type {
|
|
6
|
+
SpacingToken,
|
|
7
|
+
AlignToken,
|
|
8
|
+
BackgroundToken,
|
|
9
|
+
RadiusToken,
|
|
10
|
+
ColorToken,
|
|
11
|
+
SizeToken,
|
|
12
|
+
WeightToken,
|
|
13
|
+
FitToken,
|
|
14
|
+
} from '../schemas/tokens'
|
|
15
|
+
// TokensConfig type - must match resolver.ts
|
|
16
|
+
// Duplicated here to avoid importing Node.js-only resolver in browser
|
|
17
|
+
export interface TokensConfig {
|
|
18
|
+
colors: Record<string, string>
|
|
19
|
+
backgrounds: Record<string, string>
|
|
20
|
+
spacing: Record<string, string>
|
|
21
|
+
typography: {
|
|
22
|
+
sizes: Record<string, string>
|
|
23
|
+
weights: Record<string, number>
|
|
24
|
+
}
|
|
25
|
+
radius: Record<string, string>
|
|
26
|
+
shadows: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================
|
|
30
|
+
// Token resolution with caching
|
|
31
|
+
// ============================================================
|
|
32
|
+
|
|
33
|
+
// Cached tokens - must be set via setTokensConfig() before rendering
|
|
34
|
+
let cachedTokens: TokensConfig | null = null
|
|
35
|
+
|
|
36
|
+
// Get cached tokens - throws if not initialized
|
|
37
|
+
// In browser: setTokensConfig() must be called first
|
|
38
|
+
// In Node.js tests: setTokensConfig() with resolved tokens
|
|
39
|
+
function getTokens(): TokensConfig {
|
|
40
|
+
if (!cachedTokens) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Tokens not initialized. Call setTokensConfig(tokens) before rendering. ' +
|
|
43
|
+
'In browser preview, this should be done automatically by the preview runtime.'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return cachedTokens
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Allow setting custom tokens (for testing and runtime override)
|
|
50
|
+
export function setTokensConfig(tokens: TokensConfig | undefined): void {
|
|
51
|
+
cachedTokens = tokens || null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Helper functions to get token values - throw on missing tokens to surface config bugs early
|
|
55
|
+
function getSpacing(token: SpacingToken): string {
|
|
56
|
+
const value = getTokens().spacing[token]
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
throw new Error(`Missing spacing token: "${token}". Check your tokens.yaml configuration.`)
|
|
59
|
+
}
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getBackground(token: BackgroundToken): string {
|
|
64
|
+
const value = getTokens().backgrounds[token]
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
throw new Error(`Missing background token: "${token}". Check your tokens.yaml configuration.`)
|
|
67
|
+
}
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getRadius(token: RadiusToken): string {
|
|
72
|
+
const value = getTokens().radius[token]
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
throw new Error(`Missing radius token: "${token}". Check your tokens.yaml configuration.`)
|
|
75
|
+
}
|
|
76
|
+
return value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getColor(token: ColorToken): string {
|
|
80
|
+
const value = getTokens().colors[token]
|
|
81
|
+
if (value === undefined) {
|
|
82
|
+
throw new Error(`Missing color token: "${token}". Check your tokens.yaml configuration.`)
|
|
83
|
+
}
|
|
84
|
+
return value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getTypographySize(token: SizeToken): string {
|
|
88
|
+
const value = getTokens().typography.sizes[token]
|
|
89
|
+
if (value === undefined) {
|
|
90
|
+
throw new Error(`Missing typography.sizes token: "${token}". Check your tokens.yaml configuration.`)
|
|
91
|
+
}
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getTypographyWeight(token: WeightToken): number {
|
|
96
|
+
const value = getTokens().typography.weights[token]
|
|
97
|
+
if (value === undefined) {
|
|
98
|
+
throw new Error(`Missing typography.weights token: "${token}". Check your tokens.yaml configuration.`)
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================
|
|
104
|
+
// Non-token style mappings (CSS values, not design tokens)
|
|
105
|
+
// ============================================================
|
|
106
|
+
|
|
107
|
+
const ALIGN_VALUES: Record<AlignToken, React.CSSProperties> = {
|
|
108
|
+
start: { alignItems: 'flex-start' },
|
|
109
|
+
center: { alignItems: 'center' },
|
|
110
|
+
end: { alignItems: 'flex-end' },
|
|
111
|
+
stretch: { alignItems: 'stretch' },
|
|
112
|
+
between: { justifyContent: 'space-between' },
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ICON_SIZE_VALUES: Record<SizeToken, string> = {
|
|
116
|
+
xs: '12px',
|
|
117
|
+
sm: '16px',
|
|
118
|
+
base: '20px',
|
|
119
|
+
lg: '24px',
|
|
120
|
+
xl: '28px',
|
|
121
|
+
'2xl': '32px',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const FIT_VALUES: Record<FitToken, React.CSSProperties['objectFit']> = {
|
|
125
|
+
cover: 'cover',
|
|
126
|
+
contain: 'contain',
|
|
127
|
+
fill: 'fill',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// Render context
|
|
132
|
+
// ============================================================
|
|
133
|
+
|
|
134
|
+
export interface ReactRenderContext {
|
|
135
|
+
/** Current state for slot resolution */
|
|
136
|
+
state?: string
|
|
137
|
+
/** Slots mapping: slotName -> stateName -> VNode */
|
|
138
|
+
slots?: Record<string, Record<string, VNodeType>>
|
|
139
|
+
/** Callback to render component nodes */
|
|
140
|
+
renderComponent?: (name: string, props: Record<string, unknown>, children?: React.ReactNode) => React.ReactNode
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ============================================================
|
|
144
|
+
// Main render function
|
|
145
|
+
// ============================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render a VNode tree to React elements
|
|
149
|
+
*/
|
|
150
|
+
export function toReact(node: VNodeType | VNodeType[], context: ReactRenderContext = {}): React.ReactNode {
|
|
151
|
+
if (Array.isArray(node)) {
|
|
152
|
+
return node.map((n, i) => <React.Fragment key={n.id || i}>{renderNode(n, context)}</React.Fragment>)
|
|
153
|
+
}
|
|
154
|
+
return renderNode(node, context)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderNode(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
158
|
+
switch (node.type) {
|
|
159
|
+
case 'col':
|
|
160
|
+
return renderCol(node, context)
|
|
161
|
+
case 'row':
|
|
162
|
+
return renderRow(node, context)
|
|
163
|
+
case 'box':
|
|
164
|
+
return renderBox(node, context)
|
|
165
|
+
case 'spacer':
|
|
166
|
+
return renderSpacer(node)
|
|
167
|
+
case 'slot':
|
|
168
|
+
return renderSlot(node, context)
|
|
169
|
+
case 'text':
|
|
170
|
+
return renderText(node)
|
|
171
|
+
case 'icon':
|
|
172
|
+
return renderIcon(node)
|
|
173
|
+
case 'image':
|
|
174
|
+
return renderImage(node)
|
|
175
|
+
case 'component':
|
|
176
|
+
return renderComponent(node, context)
|
|
177
|
+
default:
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderChildren(children: VNodeType[] | undefined, context: ReactRenderContext): React.ReactNode {
|
|
183
|
+
if (!children || children.length === 0) return null
|
|
184
|
+
return children.map((child) => (
|
|
185
|
+
<React.Fragment key={child.id}>{renderNode(child, context)}</React.Fragment>
|
|
186
|
+
))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================
|
|
190
|
+
// Primitive renderers
|
|
191
|
+
// ============================================================
|
|
192
|
+
|
|
193
|
+
function renderCol(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
194
|
+
const { gap, align, padding } = node.props as { gap?: SpacingToken; align?: AlignToken; padding?: SpacingToken }
|
|
195
|
+
|
|
196
|
+
const style: React.CSSProperties = {
|
|
197
|
+
display: 'flex',
|
|
198
|
+
flexDirection: 'column',
|
|
199
|
+
...(gap && { gap: getSpacing(gap) }),
|
|
200
|
+
...(align && ALIGN_VALUES[align]),
|
|
201
|
+
...(padding && { padding: getSpacing(padding) }),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div data-primitive="col" data-node-id={node.id} style={style}>
|
|
206
|
+
{renderChildren(node.children, context)}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderRow(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
212
|
+
const { gap, align, padding } = node.props as { gap?: SpacingToken; align?: AlignToken; padding?: SpacingToken }
|
|
213
|
+
|
|
214
|
+
const style: React.CSSProperties = {
|
|
215
|
+
display: 'flex',
|
|
216
|
+
flexDirection: 'row',
|
|
217
|
+
alignItems: 'center', // Row defaults to center
|
|
218
|
+
...(gap && { gap: getSpacing(gap) }),
|
|
219
|
+
...(align && ALIGN_VALUES[align]),
|
|
220
|
+
...(padding && { padding: getSpacing(padding) }),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div data-primitive="row" data-node-id={node.id} style={style}>
|
|
225
|
+
{renderChildren(node.children, context)}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function renderBox(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
231
|
+
const { padding, bg, radius } = node.props as { padding?: SpacingToken; bg?: BackgroundToken; radius?: RadiusToken }
|
|
232
|
+
|
|
233
|
+
const style: React.CSSProperties = {
|
|
234
|
+
...(padding && { padding: getSpacing(padding) }),
|
|
235
|
+
...(bg && { background: getBackground(bg) }),
|
|
236
|
+
...(radius && { borderRadius: getRadius(radius) }),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div data-primitive="box" data-node-id={node.id} style={style}>
|
|
241
|
+
{renderChildren(node.children, context)}
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderSpacer(node: VNodeType): React.ReactNode {
|
|
247
|
+
const { size } = node.props as { size?: SpacingToken }
|
|
248
|
+
|
|
249
|
+
const style: React.CSSProperties = size
|
|
250
|
+
? { width: getSpacing(size), height: getSpacing(size), flexShrink: 0 }
|
|
251
|
+
: { flex: 1 }
|
|
252
|
+
|
|
253
|
+
return <div data-primitive="spacer" data-node-id={node.id} style={style} />
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderSlot(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
257
|
+
const { name } = node.props as { name: string }
|
|
258
|
+
const state = context.state || 'default'
|
|
259
|
+
|
|
260
|
+
if (context.slots && context.slots[name]) {
|
|
261
|
+
const stateMapping = context.slots[name]
|
|
262
|
+
const content = stateMapping[state] || stateMapping.default
|
|
263
|
+
|
|
264
|
+
if (content) {
|
|
265
|
+
return renderNode(content, context)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return <div data-primitive="slot" data-slot-name={name} data-node-id={node.id} />
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderText(node: VNodeType): React.ReactNode {
|
|
273
|
+
const { content, size, weight, color } = node.props as {
|
|
274
|
+
content?: string
|
|
275
|
+
size?: SizeToken
|
|
276
|
+
weight?: WeightToken
|
|
277
|
+
color?: ColorToken
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const style: React.CSSProperties = {
|
|
281
|
+
...(size && { fontSize: getTypographySize(size) }),
|
|
282
|
+
...(weight && { fontWeight: getTypographyWeight(weight) }),
|
|
283
|
+
...(color && { color: getColor(color) }),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<span data-primitive="text" data-node-id={node.id} style={style}>
|
|
288
|
+
{content || ''}
|
|
289
|
+
</span>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderIcon(node: VNodeType): React.ReactNode {
|
|
294
|
+
const { name, size, color } = node.props as { name: string; size?: SizeToken; color?: ColorToken }
|
|
295
|
+
|
|
296
|
+
const iconSize = size ? ICON_SIZE_VALUES[size] : ICON_SIZE_VALUES.base
|
|
297
|
+
|
|
298
|
+
const style: React.CSSProperties = {
|
|
299
|
+
display: 'inline-flex',
|
|
300
|
+
alignItems: 'center',
|
|
301
|
+
justifyContent: 'center',
|
|
302
|
+
width: iconSize,
|
|
303
|
+
height: iconSize,
|
|
304
|
+
...(color && { color: getColor(color) }),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Placeholder for icon - in real implementation, would use icon library
|
|
308
|
+
return (
|
|
309
|
+
<span data-primitive="icon" data-icon={name} data-node-id={node.id} style={style}>
|
|
310
|
+
{/* Icon placeholder */}
|
|
311
|
+
</span>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function renderImage(node: VNodeType): React.ReactNode {
|
|
316
|
+
const { src, alt, fit } = node.props as { src: string; alt?: string; fit?: FitToken }
|
|
317
|
+
|
|
318
|
+
const style: React.CSSProperties = {
|
|
319
|
+
maxWidth: '100%',
|
|
320
|
+
...(fit && { objectFit: FIT_VALUES[fit] }),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<img
|
|
325
|
+
data-primitive="image"
|
|
326
|
+
data-node-id={node.id}
|
|
327
|
+
src={src}
|
|
328
|
+
alt={alt || ''}
|
|
329
|
+
style={style}
|
|
330
|
+
/>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function renderComponent(node: VNodeType, context: ReactRenderContext): React.ReactNode {
|
|
335
|
+
const componentName = node.componentName || 'Unknown'
|
|
336
|
+
|
|
337
|
+
if (context.renderComponent) {
|
|
338
|
+
return context.renderComponent(componentName, node.props, renderChildren(node.children, context))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div data-component={componentName} data-node-id={node.id}>
|
|
343
|
+
{renderChildren(node.children, context)}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// src/jsx/define-component.ts
|
|
2
|
+
// API for defining custom components with typed props and states
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { createComponentVNode, type VNodeType } from './vnode'
|
|
5
|
+
import { validateProps } from './validation'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render context passed to component render function
|
|
9
|
+
*/
|
|
10
|
+
export interface ComponentContext<TProps, TState> {
|
|
11
|
+
props: TProps
|
|
12
|
+
state: TState
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Component definition options
|
|
17
|
+
*/
|
|
18
|
+
export interface ComponentDefinition<
|
|
19
|
+
TProps extends z.ZodType,
|
|
20
|
+
TStates extends z.ZodType,
|
|
21
|
+
> {
|
|
22
|
+
/** Component name (used in VNode tree) */
|
|
23
|
+
name: string
|
|
24
|
+
/** Zod schema for props */
|
|
25
|
+
props: TProps
|
|
26
|
+
/** Zod schema for states */
|
|
27
|
+
states: TStates
|
|
28
|
+
/** Default state value */
|
|
29
|
+
defaultState: z.infer<TStates>
|
|
30
|
+
/** Render function that returns a VNode tree */
|
|
31
|
+
render: (ctx: ComponentContext<z.infer<TProps>, z.infer<TStates>>) => VNodeType
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Component function type returned by defineComponent
|
|
36
|
+
*/
|
|
37
|
+
export type ComponentFunction<TProps, TState> = {
|
|
38
|
+
(props: TProps, state?: TState): VNodeType
|
|
39
|
+
componentName: string
|
|
40
|
+
propsSchema: z.ZodType<TProps>
|
|
41
|
+
statesSchema: z.ZodType<TState>
|
|
42
|
+
defaultState: TState
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Define a reusable component with typed props and states
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* const Button = defineComponent({
|
|
51
|
+
* name: 'Button',
|
|
52
|
+
* props: z.object({
|
|
53
|
+
* label: z.string(),
|
|
54
|
+
* variant: z.enum(['primary', 'secondary']).optional(),
|
|
55
|
+
* }),
|
|
56
|
+
* states: z.enum(['idle', 'loading', 'disabled']),
|
|
57
|
+
* defaultState: 'idle',
|
|
58
|
+
* render: ({ props, state }) => (
|
|
59
|
+
* <Box bg={state === 'loading' ? 'muted' : 'primary'} padding="md" radius="md">
|
|
60
|
+
* <Text>{props.label}</Text>
|
|
61
|
+
* </Box>
|
|
62
|
+
* ),
|
|
63
|
+
* })
|
|
64
|
+
*
|
|
65
|
+
* // Usage
|
|
66
|
+
* <Button label="Click me" />
|
|
67
|
+
* <Button label="Processing..." state="loading" />
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function defineComponent<
|
|
71
|
+
TProps extends z.ZodType,
|
|
72
|
+
TStates extends z.ZodType,
|
|
73
|
+
>(
|
|
74
|
+
definition: ComponentDefinition<TProps, TStates>
|
|
75
|
+
): ComponentFunction<z.infer<TProps>, z.infer<TStates>> {
|
|
76
|
+
const { name, props: propsSchema, states: statesSchema, defaultState, render } = definition
|
|
77
|
+
|
|
78
|
+
// Validate default state at definition time
|
|
79
|
+
statesSchema.parse(defaultState)
|
|
80
|
+
|
|
81
|
+
const Component = function (
|
|
82
|
+
props: z.infer<TProps>,
|
|
83
|
+
state?: z.infer<TStates>
|
|
84
|
+
): VNodeType {
|
|
85
|
+
// Validate props based on current validation mode
|
|
86
|
+
validateProps(propsSchema, props, name)
|
|
87
|
+
|
|
88
|
+
// Use provided state or default
|
|
89
|
+
const resolvedState = state !== undefined ? state : defaultState
|
|
90
|
+
|
|
91
|
+
// Validate state if provided
|
|
92
|
+
if (state !== undefined) {
|
|
93
|
+
validateProps(statesSchema, state, `${name}[state]`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Render the component
|
|
97
|
+
const rendered = render({ props, state: resolvedState })
|
|
98
|
+
|
|
99
|
+
// Wrap in component VNode
|
|
100
|
+
return createComponentVNode(name, props as Record<string, unknown>, [rendered])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Attach metadata
|
|
104
|
+
Component.componentName = name
|
|
105
|
+
Component.propsSchema = propsSchema
|
|
106
|
+
Component.statesSchema = statesSchema
|
|
107
|
+
Component.defaultState = defaultState
|
|
108
|
+
|
|
109
|
+
return Component as ComponentFunction<z.infer<TProps>, z.infer<TStates>>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Helper to create a simple component without states
|
|
114
|
+
*/
|
|
115
|
+
export function defineStatelessComponent<TProps extends z.ZodType>(definition: {
|
|
116
|
+
name: string
|
|
117
|
+
props: TProps
|
|
118
|
+
render: (props: z.infer<TProps>) => VNodeType
|
|
119
|
+
}): (props: z.infer<TProps>) => VNodeType {
|
|
120
|
+
const NoState = z.literal('default')
|
|
121
|
+
|
|
122
|
+
return defineComponent({
|
|
123
|
+
name: definition.name,
|
|
124
|
+
props: definition.props,
|
|
125
|
+
states: NoState,
|
|
126
|
+
defaultState: 'default',
|
|
127
|
+
render: ({ props }) => definition.render(props),
|
|
128
|
+
})
|
|
129
|
+
}
|
package/src/jsx/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/jsx/index.ts
|
|
2
|
+
// Main entry point for JSX primitives
|
|
3
|
+
|
|
4
|
+
// Schemas
|
|
5
|
+
export * from './schemas'
|
|
6
|
+
|
|
7
|
+
// VNode
|
|
8
|
+
export {
|
|
9
|
+
createVNode,
|
|
10
|
+
createComponentVNode,
|
|
11
|
+
normalizeChildren,
|
|
12
|
+
vnodeEquals,
|
|
13
|
+
resetIdCounter,
|
|
14
|
+
VNode,
|
|
15
|
+
type VNodeType,
|
|
16
|
+
} from './vnode'
|
|
17
|
+
|
|
18
|
+
// JSX runtime (for direct use)
|
|
19
|
+
export {
|
|
20
|
+
Col,
|
|
21
|
+
Row,
|
|
22
|
+
Box,
|
|
23
|
+
Spacer,
|
|
24
|
+
Slot,
|
|
25
|
+
Text,
|
|
26
|
+
Icon,
|
|
27
|
+
Image,
|
|
28
|
+
Fragment,
|
|
29
|
+
} from './jsx-runtime'
|
|
30
|
+
|
|
31
|
+
// defineComponent API
|
|
32
|
+
export {
|
|
33
|
+
defineComponent,
|
|
34
|
+
defineStatelessComponent,
|
|
35
|
+
type ComponentContext,
|
|
36
|
+
type ComponentDefinition,
|
|
37
|
+
type ComponentFunction,
|
|
38
|
+
} from './define-component'
|
|
39
|
+
|
|
40
|
+
// HTML adapter
|
|
41
|
+
export { renderToHtml, type RenderContext } from './adapters/html'
|
|
42
|
+
|
|
43
|
+
// React adapter
|
|
44
|
+
export { toReact, setTokensConfig, type ReactRenderContext, type TokensConfig } from './adapters/react'
|
|
45
|
+
|
|
46
|
+
// Migration tool
|
|
47
|
+
export { migrateYamlToJsx, type MigrationResult, type YamlConfig } from './migrate'
|