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,63 @@
|
|
|
1
|
+
// src/jsx/jsx-runtime.test.ts
|
|
2
|
+
import { test, expect, describe, beforeEach, afterEach, spyOn } from 'bun:test'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { createVNode, resetIdCounter, createRenderContext, normalizeChildren, vnodeEquals } from './vnode'
|
|
5
|
+
import { Col, Box, Slot, Text, Icon, Fragment, setValidationMode, getValidationMode } from './jsx-runtime'
|
|
6
|
+
import { validateProps, type ValidationMode } from './validation'
|
|
7
|
+
import { defineComponent, defineStatelessComponent } from './define-component'
|
|
8
|
+
|
|
9
|
+
let originalMode: ValidationMode
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
resetIdCounter()
|
|
13
|
+
originalMode = getValidationMode()
|
|
14
|
+
setValidationMode('strict')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
setValidationMode(originalMode)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('jsx-runtime', () => {
|
|
22
|
+
test('vnode, primitives, components, validation', () => {
|
|
23
|
+
// vnode internals
|
|
24
|
+
const ctx1 = createRenderContext()
|
|
25
|
+
const ctx2 = createRenderContext()
|
|
26
|
+
ctx1.nextId('col')
|
|
27
|
+
expect(ctx2.nextId('col')).toBe('col-0')
|
|
28
|
+
expect(normalizeChildren(['hello'])[0].props.content).toBe('hello')
|
|
29
|
+
const a = createVNode('col', { gap: 'lg' })
|
|
30
|
+
resetIdCounter()
|
|
31
|
+
expect(vnodeEquals(a, createVNode('col', { gap: 'lg' }))).toBe(true)
|
|
32
|
+
|
|
33
|
+
// primitives
|
|
34
|
+
expect(Col({ gap: 'lg' }).type).toBe('col')
|
|
35
|
+
expect(Fragment({ children: [Text({ children: 'A' })] })).toHaveLength(1)
|
|
36
|
+
expect(Col({ children: [Text({ children: 'X' })] }).children![0].props.content).toBe('X')
|
|
37
|
+
expect(() => Col({ gap: 'invalid' as any })).toThrow()
|
|
38
|
+
expect(() => Slot({} as any)).toThrow()
|
|
39
|
+
expect(() => Icon({} as any)).toThrow()
|
|
40
|
+
|
|
41
|
+
// defineComponent
|
|
42
|
+
const Button = defineComponent({
|
|
43
|
+
name: 'Button',
|
|
44
|
+
props: z.object({ label: z.string() }),
|
|
45
|
+
states: z.enum(['idle', 'loading']),
|
|
46
|
+
defaultState: 'idle',
|
|
47
|
+
render: ({ props, state }) => Box({ bg: state === 'loading' ? 'muted' : 'primary', children: [Text({ children: props.label })] }),
|
|
48
|
+
})
|
|
49
|
+
expect(Button({ label: 'Click' }).componentName).toBe('Button')
|
|
50
|
+
expect(Button({ label: 'X' }, 'loading').children![0].props.bg).toBe('muted')
|
|
51
|
+
expect(() => Button({ label: 123 } as any)).toThrow()
|
|
52
|
+
expect(defineStatelessComponent({ name: 'L', props: z.object({ t: z.string() }), render: (p) => Text({ children: p.t }) })({ t: 'Hi' }).componentName).toBe('L')
|
|
53
|
+
|
|
54
|
+
// validation modes
|
|
55
|
+
setValidationMode('warn')
|
|
56
|
+
const spy = spyOn(console, 'warn').mockImplementation(() => {})
|
|
57
|
+
expect(() => validateProps(z.object({ n: z.string() }), { n: 1 }, 'T')).not.toThrow()
|
|
58
|
+
expect(spy).toHaveBeenCalled()
|
|
59
|
+
spy.mockRestore()
|
|
60
|
+
setValidationMode('off')
|
|
61
|
+
expect(() => validateProps(z.object({ n: z.string() }), { n: 1 }, 'T')).not.toThrow()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/jsx/jsx-runtime.ts
|
|
2
|
+
// JSX runtime for Vite's automatic JSX transform
|
|
3
|
+
import {
|
|
4
|
+
createVNode,
|
|
5
|
+
normalizeChildren,
|
|
6
|
+
type VNodeType,
|
|
7
|
+
} from './vnode'
|
|
8
|
+
import {
|
|
9
|
+
ColProps,
|
|
10
|
+
RowProps,
|
|
11
|
+
BoxProps,
|
|
12
|
+
SpacerProps,
|
|
13
|
+
SlotProps,
|
|
14
|
+
TextProps,
|
|
15
|
+
IconProps,
|
|
16
|
+
ImageProps,
|
|
17
|
+
} from './schemas/primitives'
|
|
18
|
+
import { validateProps } from './validation'
|
|
19
|
+
|
|
20
|
+
// Re-export validation utilities for consumers
|
|
21
|
+
export { setValidationMode, getValidationMode, type ValidationMode } from './validation'
|
|
22
|
+
|
|
23
|
+
// ============================================================
|
|
24
|
+
// Primitive Components
|
|
25
|
+
// ============================================================
|
|
26
|
+
|
|
27
|
+
export function Col(props: ColProps): VNodeType {
|
|
28
|
+
validateProps(ColProps, props, 'Col')
|
|
29
|
+
const { children, ...rest } = props
|
|
30
|
+
return createVNode('col', rest, normalizeChildren(children))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Row(props: RowProps): VNodeType {
|
|
34
|
+
validateProps(RowProps, props, 'Row')
|
|
35
|
+
const { children, ...rest } = props
|
|
36
|
+
return createVNode('row', rest, normalizeChildren(children))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Box(props: BoxProps): VNodeType {
|
|
40
|
+
validateProps(BoxProps, props, 'Box')
|
|
41
|
+
const { children, ...rest } = props
|
|
42
|
+
return createVNode('box', rest, normalizeChildren(children))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Spacer(props: SpacerProps = {}): VNodeType {
|
|
46
|
+
validateProps(SpacerProps, props, 'Spacer')
|
|
47
|
+
return createVNode('spacer', props)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Slot(props: SlotProps): VNodeType {
|
|
51
|
+
validateProps(SlotProps, props, 'Slot')
|
|
52
|
+
return createVNode('slot', props)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function Text(props: TextProps): VNodeType {
|
|
56
|
+
validateProps(TextProps, props, 'Text')
|
|
57
|
+
const { children, ...rest } = props
|
|
58
|
+
return createVNode('text', { ...rest, content: children })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Icon(props: IconProps): VNodeType {
|
|
62
|
+
validateProps(IconProps, props, 'Icon')
|
|
63
|
+
return createVNode('icon', props)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Image(props: ImageProps): VNodeType {
|
|
67
|
+
validateProps(ImageProps, props, 'Image')
|
|
68
|
+
return createVNode('image', props)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================
|
|
72
|
+
// JSX Runtime Functions
|
|
73
|
+
// ============================================================
|
|
74
|
+
|
|
75
|
+
type JSXElementType = ((props: Record<string, unknown>, state?: unknown) => VNodeType) & {
|
|
76
|
+
statesSchema?: unknown // Set by defineComponent
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* JSX factory function - called for each JSX element
|
|
81
|
+
* Extracts `state` prop for components created with defineComponent
|
|
82
|
+
*/
|
|
83
|
+
export function jsx(type: JSXElementType, props: Record<string, unknown>): VNodeType {
|
|
84
|
+
// Check if this is a defineComponent component (has statesSchema)
|
|
85
|
+
if (type.statesSchema && 'state' in props) {
|
|
86
|
+
const { state, ...restProps } = props
|
|
87
|
+
return type(restProps, state)
|
|
88
|
+
}
|
|
89
|
+
return type(props)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* JSX factory for elements with static children
|
|
94
|
+
*/
|
|
95
|
+
export const jsxs = jsx
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* JSX factory for development mode (with source info)
|
|
99
|
+
*/
|
|
100
|
+
export function jsxDEV(
|
|
101
|
+
type: JSXElementType,
|
|
102
|
+
props: Record<string, unknown>,
|
|
103
|
+
_key?: string,
|
|
104
|
+
_isStaticChildren?: boolean,
|
|
105
|
+
_source?: { fileName: string; lineNumber: number; columnNumber: number },
|
|
106
|
+
_self?: unknown
|
|
107
|
+
): VNodeType {
|
|
108
|
+
return jsx(type, props)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================
|
|
112
|
+
// Fragment support
|
|
113
|
+
// ============================================================
|
|
114
|
+
|
|
115
|
+
export function Fragment({ children }: { children?: unknown }): VNodeType[] {
|
|
116
|
+
return normalizeChildren(children)
|
|
117
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/jsx/migrate.test.ts
|
|
2
|
+
import { test, expect, describe } from 'bun:test'
|
|
3
|
+
import { migrateYamlToJsx } from './migrate'
|
|
4
|
+
|
|
5
|
+
describe('migrateYamlToJsx', () => {
|
|
6
|
+
test('migrates all primitives, states, slots, props, and handles errors', () => {
|
|
7
|
+
const componentYaml = `
|
|
8
|
+
kind: component
|
|
9
|
+
id: card-layout
|
|
10
|
+
title: Card Layout
|
|
11
|
+
template:
|
|
12
|
+
root:
|
|
13
|
+
type: $col(gap:md padding:lg)
|
|
14
|
+
children:
|
|
15
|
+
row:
|
|
16
|
+
type: $row(gap:sm)
|
|
17
|
+
children:
|
|
18
|
+
icon: $icon("star" size:lg)
|
|
19
|
+
title: $text("Title & <tags>" size:xl)
|
|
20
|
+
spacer: $spacer(xl)
|
|
21
|
+
image: $image(src:"img.png" fit:cover)
|
|
22
|
+
footer: $box(bg:muted)
|
|
23
|
+
`
|
|
24
|
+
const result = migrateYamlToJsx(componentYaml)
|
|
25
|
+
expect(result.success).toBe(true)
|
|
26
|
+
expect(result.jsx).toContain('<Col gap="md"')
|
|
27
|
+
expect(result.jsx).toContain('<Row gap="sm"')
|
|
28
|
+
expect(result.jsx).toContain('<Icon name="star"')
|
|
29
|
+
expect(result.jsx).toContain('&')
|
|
30
|
+
expect(result.jsx).toContain('<')
|
|
31
|
+
expect(result.jsx).toContain('<Spacer size="xl"')
|
|
32
|
+
expect(result.jsx).toContain('<Image src="img.png"')
|
|
33
|
+
|
|
34
|
+
const screenYaml = `
|
|
35
|
+
kind: screen
|
|
36
|
+
id: test
|
|
37
|
+
title: Test
|
|
38
|
+
states:
|
|
39
|
+
on: {}
|
|
40
|
+
off: {}
|
|
41
|
+
template:
|
|
42
|
+
root:
|
|
43
|
+
type: $col
|
|
44
|
+
children:
|
|
45
|
+
slot: $slot(actions)
|
|
46
|
+
slots:
|
|
47
|
+
actions:
|
|
48
|
+
on: $text("On")
|
|
49
|
+
off: $text("Off")
|
|
50
|
+
`
|
|
51
|
+
const screenResult = migrateYamlToJsx(screenYaml)
|
|
52
|
+
expect(screenResult.success).toBe(true)
|
|
53
|
+
expect(screenResult.jsx).toContain("z.enum(['on', 'off'])")
|
|
54
|
+
expect(screenResult.jsx).toContain('<Slot name="actions"')
|
|
55
|
+
expect(screenResult.jsx).toContain('const slots = {')
|
|
56
|
+
|
|
57
|
+
const propsYaml = `
|
|
58
|
+
kind: component
|
|
59
|
+
id: x
|
|
60
|
+
title: X
|
|
61
|
+
props:
|
|
62
|
+
label:
|
|
63
|
+
type: string
|
|
64
|
+
required: true
|
|
65
|
+
template:
|
|
66
|
+
root: $text(label)
|
|
67
|
+
`
|
|
68
|
+
expect(migrateYamlToJsx(propsYaml).jsx).toContain('{props.label}')
|
|
69
|
+
expect(migrateYamlToJsx('invalid: [yaml').success).toBe(false)
|
|
70
|
+
expect(migrateYamlToJsx('kind: x\nid: x\ntitle: X').errors).toContain('No template.root found in config')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// src/jsx/migrate.ts
|
|
2
|
+
// Migration tool: converts YAML config to JSX
|
|
3
|
+
import { load as parseYaml } from 'js-yaml'
|
|
4
|
+
import { parsePrimitive, isQuoted } from '../primitives/parser'
|
|
5
|
+
import type { TemplateNode, Slots } from '../primitives/types'
|
|
6
|
+
|
|
7
|
+
export interface MigrationResult {
|
|
8
|
+
success: boolean
|
|
9
|
+
jsx?: string
|
|
10
|
+
errors: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface YamlConfig {
|
|
14
|
+
kind: string
|
|
15
|
+
id: string
|
|
16
|
+
title: string
|
|
17
|
+
schemaVersion?: string
|
|
18
|
+
states?: Record<string, { description?: string }>
|
|
19
|
+
template?: { root: TemplateNode }
|
|
20
|
+
slots?: Slots
|
|
21
|
+
props?: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Migrate a YAML config file to JSX/TSX
|
|
26
|
+
*/
|
|
27
|
+
export function migrateYamlToJsx(yamlContent: string): MigrationResult {
|
|
28
|
+
const errors: string[] = []
|
|
29
|
+
|
|
30
|
+
let config: YamlConfig
|
|
31
|
+
try {
|
|
32
|
+
config = parseYaml(yamlContent) as YamlConfig
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { success: false, errors: [`YAML parse error: ${e}`] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!config.template?.root) {
|
|
38
|
+
return { success: false, errors: ['No template.root found in config'] }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const componentName = pascalCase(config.id || 'Component')
|
|
42
|
+
const hasStates = config.states && Object.keys(config.states).length > 0
|
|
43
|
+
const hasSlots = config.slots && Object.keys(config.slots).length > 0
|
|
44
|
+
const hasProps = config.props && Object.keys(config.props).length > 0
|
|
45
|
+
|
|
46
|
+
// Build imports
|
|
47
|
+
const imports = new Set<string>()
|
|
48
|
+
collectImports(config.template.root, imports)
|
|
49
|
+
|
|
50
|
+
// Generate state type
|
|
51
|
+
const stateNames = hasStates ? Object.keys(config.states!) : ['default']
|
|
52
|
+
|
|
53
|
+
// Generate JSX tree
|
|
54
|
+
const jsxTree = convertNodeToJsx(config.template.root, 2, errors)
|
|
55
|
+
|
|
56
|
+
// Generate slots if present
|
|
57
|
+
let slotsCode = ''
|
|
58
|
+
if (hasSlots) {
|
|
59
|
+
slotsCode = generateSlotsCode(config.slots!, imports, errors)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build the output
|
|
63
|
+
const output = `// Generated from ${config.id || 'config'}.yaml
|
|
64
|
+
// ${config.title}
|
|
65
|
+
import { z } from 'zod'
|
|
66
|
+
import {
|
|
67
|
+
${Array.from(imports).sort().join(',\n ')},
|
|
68
|
+
defineComponent,
|
|
69
|
+
} from '@prev-cli/jsx'
|
|
70
|
+
|
|
71
|
+
${hasProps ? generatePropsSchema(config.props!) : `const PropsSchema = z.object({})`}
|
|
72
|
+
|
|
73
|
+
const StatesSchema = z.enum([${stateNames.map(s => `'${s}'`).join(', ')}])
|
|
74
|
+
|
|
75
|
+
${slotsCode}
|
|
76
|
+
export const ${componentName} = defineComponent({
|
|
77
|
+
name: '${componentName}',
|
|
78
|
+
props: PropsSchema,
|
|
79
|
+
states: StatesSchema,
|
|
80
|
+
defaultState: '${stateNames[0]}',
|
|
81
|
+
render: ({ props, state }) => (
|
|
82
|
+
${jsxTree}
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export default ${componentName}
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
success: errors.length === 0,
|
|
91
|
+
jsx: output,
|
|
92
|
+
errors,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Convert a template node to JSX string
|
|
98
|
+
*/
|
|
99
|
+
function convertNodeToJsx(node: TemplateNode, indent: number, errors: string[]): string {
|
|
100
|
+
const spaces = ' '.repeat(indent)
|
|
101
|
+
|
|
102
|
+
// String node: primitive or literal
|
|
103
|
+
if (typeof node === 'string') {
|
|
104
|
+
return convertLeafToJsx(node, indent, errors)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Container node with type and children
|
|
108
|
+
if (typeof node === 'object' && node !== null && 'type' in node) {
|
|
109
|
+
const { type, children } = node as { type: string; children?: Record<string, TemplateNode> }
|
|
110
|
+
return convertContainerToJsx(type, children, indent, errors)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
errors.push(`Unknown node type: ${JSON.stringify(node)}`)
|
|
114
|
+
return `${spaces}{/* unknown node */}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Convert a leaf node (primitive string) to JSX
|
|
119
|
+
*/
|
|
120
|
+
function convertLeafToJsx(value: string, indent: number, errors: string[]): string {
|
|
121
|
+
const spaces = ' '.repeat(indent)
|
|
122
|
+
|
|
123
|
+
// Check if it's a primitive
|
|
124
|
+
if (value.startsWith('$')) {
|
|
125
|
+
const result = parsePrimitive(value)
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
errors.push(`Invalid primitive: ${value} - ${result.error.message}`)
|
|
128
|
+
return `${spaces}{/* invalid: ${value} */}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const primitive = result.primitive
|
|
132
|
+
switch (primitive.type) {
|
|
133
|
+
case '$text':
|
|
134
|
+
return convertTextPrimitive(primitive, indent)
|
|
135
|
+
case '$icon':
|
|
136
|
+
return convertIconPrimitive(primitive, indent)
|
|
137
|
+
case '$image':
|
|
138
|
+
return convertImagePrimitive(primitive, indent)
|
|
139
|
+
case '$spacer':
|
|
140
|
+
return convertSpacerPrimitive(primitive, indent)
|
|
141
|
+
case '$slot':
|
|
142
|
+
return convertSlotPrimitive(primitive, indent)
|
|
143
|
+
case '$col':
|
|
144
|
+
return `${spaces}<Col${propsToJsx(primitive)} />`
|
|
145
|
+
case '$row':
|
|
146
|
+
return `${spaces}<Row${propsToJsx(primitive)} />`
|
|
147
|
+
case '$box':
|
|
148
|
+
return `${spaces}<Box${propsToJsx(primitive)} />`
|
|
149
|
+
default:
|
|
150
|
+
return `${spaces}{/* ${value} */}`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Component reference
|
|
155
|
+
if (value.includes('/')) {
|
|
156
|
+
return `${spaces}{/* ref: ${value} */}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Literal text
|
|
160
|
+
return `${spaces}<Text>${escapeJsx(value)}</Text>`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Convert a container node to JSX
|
|
165
|
+
*/
|
|
166
|
+
function convertContainerToJsx(
|
|
167
|
+
type: string,
|
|
168
|
+
children: Record<string, TemplateNode> | undefined,
|
|
169
|
+
indent: number,
|
|
170
|
+
errors: string[]
|
|
171
|
+
): string {
|
|
172
|
+
const spaces = ' '.repeat(indent)
|
|
173
|
+
const result = parsePrimitive(type)
|
|
174
|
+
|
|
175
|
+
if (!result.success) {
|
|
176
|
+
errors.push(`Invalid primitive: ${type} - ${result.error.message}`)
|
|
177
|
+
return `${spaces}{/* invalid: ${type} */}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const primitive = result.primitive
|
|
181
|
+
const componentName = getComponentName(primitive.type)
|
|
182
|
+
const props = propsToJsx(primitive)
|
|
183
|
+
|
|
184
|
+
if (!children || Object.keys(children).length === 0) {
|
|
185
|
+
return `${spaces}<${componentName}${props} />`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const childrenJsx = Object.entries(children)
|
|
189
|
+
.map(([, child]) => convertNodeToJsx(child, indent + 1, errors))
|
|
190
|
+
.join('\n')
|
|
191
|
+
|
|
192
|
+
return `${spaces}<${componentName}${props}>
|
|
193
|
+
${childrenJsx}
|
|
194
|
+
${spaces}</${componentName}>`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Convert primitive type to component name
|
|
199
|
+
*/
|
|
200
|
+
function getComponentName(type: string): string {
|
|
201
|
+
const map: Record<string, string> = {
|
|
202
|
+
'$col': 'Col',
|
|
203
|
+
'$row': 'Row',
|
|
204
|
+
'$box': 'Box',
|
|
205
|
+
'$spacer': 'Spacer',
|
|
206
|
+
'$slot': 'Slot',
|
|
207
|
+
'$text': 'Text',
|
|
208
|
+
'$icon': 'Icon',
|
|
209
|
+
'$image': 'Image',
|
|
210
|
+
}
|
|
211
|
+
return map[type] || 'Unknown'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Convert primitive props to JSX attribute string
|
|
216
|
+
*/
|
|
217
|
+
function propsToJsx(primitive: object): string {
|
|
218
|
+
const attrs: string[] = []
|
|
219
|
+
|
|
220
|
+
for (const [key, value] of Object.entries(primitive)) {
|
|
221
|
+
if (key === 'type') continue
|
|
222
|
+
if (value === undefined) continue
|
|
223
|
+
|
|
224
|
+
// Handle invalid identifiers with computed property syntax
|
|
225
|
+
const propName = isValidIdentifier(key) ? key : `["${escapeJsxAttribute(key)}"]`
|
|
226
|
+
|
|
227
|
+
if (typeof value === 'string') {
|
|
228
|
+
attrs.push(`${propName}="${escapeJsxAttribute(value)}"`)
|
|
229
|
+
} else {
|
|
230
|
+
attrs.push(`${propName}={${JSON.stringify(value)}}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return attrs.length > 0 ? ' ' + attrs.join(' ') : ''
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Convert text primitive to JSX
|
|
239
|
+
*/
|
|
240
|
+
function convertTextPrimitive(
|
|
241
|
+
primitive: { content: string; size?: string; weight?: string; color?: string },
|
|
242
|
+
indent: number
|
|
243
|
+
): string {
|
|
244
|
+
const spaces = ' '.repeat(indent)
|
|
245
|
+
const attrs: string[] = []
|
|
246
|
+
|
|
247
|
+
if (primitive.size) attrs.push(`size="${escapeJsxAttribute(primitive.size)}"`)
|
|
248
|
+
if (primitive.weight) attrs.push(`weight="${escapeJsxAttribute(primitive.weight)}"`)
|
|
249
|
+
if (primitive.color) attrs.push(`color="${escapeJsxAttribute(primitive.color)}"`)
|
|
250
|
+
|
|
251
|
+
const content = isQuoted(primitive.content)
|
|
252
|
+
? escapeJsx(primitive.content.slice(1, -1))
|
|
253
|
+
: `{props.${primitive.content}}`
|
|
254
|
+
|
|
255
|
+
const propsStr = attrs.length > 0 ? ' ' + attrs.join(' ') : ''
|
|
256
|
+
return `${spaces}<Text${propsStr}>${content}</Text>`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Convert icon primitive to JSX
|
|
261
|
+
*/
|
|
262
|
+
function convertIconPrimitive(
|
|
263
|
+
primitive: { name: string; size?: string; color?: string },
|
|
264
|
+
indent: number
|
|
265
|
+
): string {
|
|
266
|
+
const spaces = ' '.repeat(indent)
|
|
267
|
+
const attrs: string[] = []
|
|
268
|
+
|
|
269
|
+
const name = isQuoted(primitive.name)
|
|
270
|
+
? `"${escapeJsxAttribute(primitive.name.slice(1, -1))}"`
|
|
271
|
+
: `{props.${primitive.name}}`
|
|
272
|
+
attrs.push(`name=${name}`)
|
|
273
|
+
|
|
274
|
+
if (primitive.size) attrs.push(`size="${escapeJsxAttribute(primitive.size)}"`)
|
|
275
|
+
if (primitive.color) attrs.push(`color="${escapeJsxAttribute(primitive.color)}"`)
|
|
276
|
+
|
|
277
|
+
return `${spaces}<Icon ${attrs.join(' ')} />`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Convert image primitive to JSX
|
|
282
|
+
*/
|
|
283
|
+
function convertImagePrimitive(
|
|
284
|
+
primitive: { src: string; alt?: string; fit?: string },
|
|
285
|
+
indent: number
|
|
286
|
+
): string {
|
|
287
|
+
const spaces = ' '.repeat(indent)
|
|
288
|
+
const attrs: string[] = []
|
|
289
|
+
|
|
290
|
+
const src = isQuoted(primitive.src)
|
|
291
|
+
? `"${escapeJsxAttribute(primitive.src.slice(1, -1))}"`
|
|
292
|
+
: `{props.${primitive.src}}`
|
|
293
|
+
attrs.push(`src=${src}`)
|
|
294
|
+
|
|
295
|
+
if (primitive.alt) attrs.push(`alt="${escapeJsxAttribute(primitive.alt)}"`)
|
|
296
|
+
if (primitive.fit) attrs.push(`fit="${escapeJsxAttribute(primitive.fit)}"`)
|
|
297
|
+
|
|
298
|
+
return `${spaces}<Image ${attrs.join(' ')} />`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Convert spacer primitive to JSX
|
|
303
|
+
*/
|
|
304
|
+
function convertSpacerPrimitive(
|
|
305
|
+
primitive: { size?: string },
|
|
306
|
+
indent: number
|
|
307
|
+
): string {
|
|
308
|
+
const spaces = ' '.repeat(indent)
|
|
309
|
+
if (primitive.size) {
|
|
310
|
+
return `${spaces}<Spacer size="${escapeJsxAttribute(primitive.size)}" />`
|
|
311
|
+
}
|
|
312
|
+
return `${spaces}<Spacer />`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Convert slot primitive to JSX
|
|
317
|
+
*/
|
|
318
|
+
function convertSlotPrimitive(
|
|
319
|
+
primitive: { name: string },
|
|
320
|
+
indent: number
|
|
321
|
+
): string {
|
|
322
|
+
const spaces = ' '.repeat(indent)
|
|
323
|
+
return `${spaces}<Slot name="${escapeJsxAttribute(primitive.name)}" />`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Collect all component imports needed
|
|
328
|
+
*/
|
|
329
|
+
function collectImports(node: TemplateNode, imports: Set<string>): void {
|
|
330
|
+
if (typeof node === 'string') {
|
|
331
|
+
if (node.startsWith('$')) {
|
|
332
|
+
const result = parsePrimitive(node)
|
|
333
|
+
if (result.success) {
|
|
334
|
+
imports.add(getComponentName(result.primitive.type))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (typeof node === 'object' && node !== null && 'type' in node) {
|
|
341
|
+
const { type, children } = node as { type: string; children?: Record<string, TemplateNode> }
|
|
342
|
+
|
|
343
|
+
const result = parsePrimitive(type)
|
|
344
|
+
if (result.success) {
|
|
345
|
+
imports.add(getComponentName(result.primitive.type))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (children) {
|
|
349
|
+
for (const child of Object.values(children)) {
|
|
350
|
+
collectImports(child, imports)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Generate props schema from config
|
|
358
|
+
*/
|
|
359
|
+
function generatePropsSchema(props: Record<string, unknown>): string {
|
|
360
|
+
const fields: string[] = []
|
|
361
|
+
|
|
362
|
+
for (const [name, def] of Object.entries(props)) {
|
|
363
|
+
const propDef = def as { type?: string; required?: boolean; default?: unknown }
|
|
364
|
+
let zodType = 'z.string()'
|
|
365
|
+
|
|
366
|
+
if (propDef.type === 'number') zodType = 'z.number()'
|
|
367
|
+
else if (propDef.type === 'boolean') zodType = 'z.boolean()'
|
|
368
|
+
|
|
369
|
+
if (!propDef.required) zodType += '.optional()'
|
|
370
|
+
|
|
371
|
+
fields.push(` ${name}: ${zodType},`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return `const PropsSchema = z.object({
|
|
375
|
+
${fields.join('\n')}
|
|
376
|
+
})`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Generate slots code
|
|
381
|
+
*/
|
|
382
|
+
function generateSlotsCode(
|
|
383
|
+
slots: Slots,
|
|
384
|
+
imports: Set<string>,
|
|
385
|
+
errors: string[]
|
|
386
|
+
): string {
|
|
387
|
+
const slotDefs: string[] = []
|
|
388
|
+
|
|
389
|
+
for (const [slotName, states] of Object.entries(slots)) {
|
|
390
|
+
const stateEntries: string[] = []
|
|
391
|
+
|
|
392
|
+
for (const [stateName, content] of Object.entries(states)) {
|
|
393
|
+
if (typeof content === 'string' && content.startsWith('$')) {
|
|
394
|
+
const jsx = convertLeafToJsx(content, 0, errors).trim()
|
|
395
|
+
stateEntries.push(` ${stateName}: ${jsx},`)
|
|
396
|
+
collectImports(content, imports)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
slotDefs.push(` ${slotName}: {
|
|
401
|
+
${stateEntries.join('\n')}
|
|
402
|
+
},`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return `const slots = {
|
|
406
|
+
${slotDefs.join('\n')}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
`
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Escape JSX special characters in text content
|
|
414
|
+
*/
|
|
415
|
+
function escapeJsx(text: string): string {
|
|
416
|
+
return text
|
|
417
|
+
.replace(/&/g, '&')
|
|
418
|
+
.replace(/</g, '<')
|
|
419
|
+
.replace(/>/g, '>')
|
|
420
|
+
.replace(/{/g, '{')
|
|
421
|
+
.replace(/}/g, '}')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Escape special characters in JSX attribute values
|
|
426
|
+
*/
|
|
427
|
+
function escapeJsxAttribute(value: string): string {
|
|
428
|
+
return value
|
|
429
|
+
.replace(/\\/g, '\\\\')
|
|
430
|
+
.replace(/"/g, '\\"')
|
|
431
|
+
.replace(/\n/g, '\\n')
|
|
432
|
+
.replace(/\r/g, '\\r')
|
|
433
|
+
.replace(/\t/g, '\\t')
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Check if a string is a valid JavaScript identifier
|
|
438
|
+
*/
|
|
439
|
+
function isValidIdentifier(name: string): boolean {
|
|
440
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Convert string to PascalCase
|
|
445
|
+
*/
|
|
446
|
+
function pascalCase(str: string): string {
|
|
447
|
+
return str
|
|
448
|
+
.split(/[-_\s]+/)
|
|
449
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
450
|
+
.join('')
|
|
451
|
+
}
|