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.
- 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,265 @@
|
|
|
1
|
+
// src/primitives/parser.test.ts
|
|
2
|
+
import { test, expect, describe } from 'bun:test'
|
|
3
|
+
import {
|
|
4
|
+
parsePrimitive,
|
|
5
|
+
getPrimitiveType,
|
|
6
|
+
isValidPrimitiveSyntax,
|
|
7
|
+
isQuoted,
|
|
8
|
+
} from './parser'
|
|
9
|
+
|
|
10
|
+
describe('parsePrimitive', () => {
|
|
11
|
+
describe('layout primitives', () => {
|
|
12
|
+
test('parses $col without props', () => {
|
|
13
|
+
const result = parsePrimitive('$col')
|
|
14
|
+
expect(result.success).toBe(true)
|
|
15
|
+
if (result.success) {
|
|
16
|
+
expect(result.primitive.type).toBe('$col')
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('parses $col with gap', () => {
|
|
21
|
+
const result = parsePrimitive('$col(gap:lg)')
|
|
22
|
+
expect(result.success).toBe(true)
|
|
23
|
+
if (result.success) {
|
|
24
|
+
expect(result.primitive.type).toBe('$col')
|
|
25
|
+
expect(result.primitive).toHaveProperty('gap', 'lg')
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('parses $col with multiple props', () => {
|
|
30
|
+
const result = parsePrimitive('$col(gap:lg align:center padding:md)')
|
|
31
|
+
expect(result.success).toBe(true)
|
|
32
|
+
if (result.success) {
|
|
33
|
+
expect(result.primitive.type).toBe('$col')
|
|
34
|
+
expect(result.primitive).toMatchObject({
|
|
35
|
+
type: '$col',
|
|
36
|
+
gap: 'lg',
|
|
37
|
+
align: 'center',
|
|
38
|
+
padding: 'md',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('parses $row', () => {
|
|
44
|
+
const result = parsePrimitive('$row(gap:sm)')
|
|
45
|
+
expect(result.success).toBe(true)
|
|
46
|
+
if (result.success) {
|
|
47
|
+
expect(result.primitive.type).toBe('$row')
|
|
48
|
+
expect(result.primitive).toHaveProperty('gap', 'sm')
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('parses $box with styling props', () => {
|
|
53
|
+
const result = parsePrimitive('$box(padding:lg bg:background radius:md)')
|
|
54
|
+
expect(result.success).toBe(true)
|
|
55
|
+
if (result.success) {
|
|
56
|
+
expect(result.primitive.type).toBe('$box')
|
|
57
|
+
expect(result.primitive).toMatchObject({
|
|
58
|
+
padding: 'lg',
|
|
59
|
+
bg: 'background',
|
|
60
|
+
radius: 'md',
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('parses $spacer without size (flex)', () => {
|
|
66
|
+
const result = parsePrimitive('$spacer')
|
|
67
|
+
expect(result.success).toBe(true)
|
|
68
|
+
if (result.success) {
|
|
69
|
+
expect(result.primitive.type).toBe('$spacer')
|
|
70
|
+
expect(result.primitive).not.toHaveProperty('size')
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('parses $spacer with positional size', () => {
|
|
75
|
+
const result = parsePrimitive('$spacer(xl)')
|
|
76
|
+
expect(result.success).toBe(true)
|
|
77
|
+
if (result.success) {
|
|
78
|
+
expect(result.primitive.type).toBe('$spacer')
|
|
79
|
+
expect(result.primitive).toHaveProperty('size', 'xl')
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('parses $slot with name', () => {
|
|
84
|
+
const result = parsePrimitive('$slot(main)')
|
|
85
|
+
expect(result.success).toBe(true)
|
|
86
|
+
if (result.success) {
|
|
87
|
+
expect(result.primitive.type).toBe('$slot')
|
|
88
|
+
expect(result.primitive).toHaveProperty('name', 'main')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('content primitives', () => {
|
|
94
|
+
test('parses $text with prop reference', () => {
|
|
95
|
+
const result = parsePrimitive('$text(label)')
|
|
96
|
+
expect(result.success).toBe(true)
|
|
97
|
+
if (result.success) {
|
|
98
|
+
expect(result.primitive.type).toBe('$text')
|
|
99
|
+
expect(result.primitive).toHaveProperty('content', 'label')
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('parses $text with quoted literal', () => {
|
|
104
|
+
const result = parsePrimitive('$text("Hello World")')
|
|
105
|
+
expect(result.success).toBe(true)
|
|
106
|
+
if (result.success) {
|
|
107
|
+
expect(result.primitive.type).toBe('$text')
|
|
108
|
+
expect(result.primitive).toHaveProperty('content', '"Hello World"')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('parses $text with colon in quoted literal', () => {
|
|
113
|
+
const result = parsePrimitive('$text("Hello: world")')
|
|
114
|
+
expect(result.success).toBe(true)
|
|
115
|
+
if (result.success) {
|
|
116
|
+
expect(result.primitive.type).toBe('$text')
|
|
117
|
+
expect(result.primitive).toHaveProperty('content', '"Hello: world"')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('parses $text with styling props', () => {
|
|
122
|
+
const result = parsePrimitive('$text(label size:lg weight:bold color:primary)')
|
|
123
|
+
expect(result.success).toBe(true)
|
|
124
|
+
if (result.success) {
|
|
125
|
+
expect(result.primitive.type).toBe('$text')
|
|
126
|
+
expect(result.primitive).toMatchObject({
|
|
127
|
+
content: 'label',
|
|
128
|
+
size: 'lg',
|
|
129
|
+
weight: 'bold',
|
|
130
|
+
color: 'primary',
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('parses $icon with name', () => {
|
|
136
|
+
const result = parsePrimitive('$icon(name:check)')
|
|
137
|
+
expect(result.success).toBe(true)
|
|
138
|
+
if (result.success) {
|
|
139
|
+
expect(result.primitive.type).toBe('$icon')
|
|
140
|
+
expect(result.primitive).toHaveProperty('name', 'check')
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('parses $icon with positional name', () => {
|
|
145
|
+
const result = parsePrimitive('$icon(iconProp)')
|
|
146
|
+
expect(result.success).toBe(true)
|
|
147
|
+
if (result.success) {
|
|
148
|
+
expect(result.primitive.type).toBe('$icon')
|
|
149
|
+
expect(result.primitive).toHaveProperty('name', 'iconProp')
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('parses $image with src', () => {
|
|
154
|
+
const result = parsePrimitive('$image(src:avatarUrl)')
|
|
155
|
+
expect(result.success).toBe(true)
|
|
156
|
+
if (result.success) {
|
|
157
|
+
expect(result.primitive.type).toBe('$image')
|
|
158
|
+
expect(result.primitive).toHaveProperty('src', 'avatarUrl')
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('parses $image with all props', () => {
|
|
163
|
+
const result = parsePrimitive('$image(src:url alt:"User" fit:cover)')
|
|
164
|
+
expect(result.success).toBe(true)
|
|
165
|
+
if (result.success) {
|
|
166
|
+
expect(result.primitive.type).toBe('$image')
|
|
167
|
+
expect(result.primitive).toMatchObject({
|
|
168
|
+
src: 'url',
|
|
169
|
+
alt: 'User',
|
|
170
|
+
fit: 'cover',
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('error cases', () => {
|
|
177
|
+
test('rejects non-$ prefix', () => {
|
|
178
|
+
const result = parsePrimitive('col')
|
|
179
|
+
expect(result.success).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('rejects unknown primitive', () => {
|
|
183
|
+
const result = parsePrimitive('$unknown')
|
|
184
|
+
expect(result.success).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('rejects invalid token value', () => {
|
|
188
|
+
const result = parsePrimitive('$col(gap:invalid)')
|
|
189
|
+
expect(result.success).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('rejects $slot without name', () => {
|
|
193
|
+
const result = parsePrimitive('$slot')
|
|
194
|
+
expect(result.success).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('rejects $text without content', () => {
|
|
198
|
+
const result = parsePrimitive('$text')
|
|
199
|
+
expect(result.success).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('rejects $icon without name', () => {
|
|
203
|
+
const result = parsePrimitive('$icon')
|
|
204
|
+
expect(result.success).toBe(false)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('rejects $image without src', () => {
|
|
208
|
+
const result = parsePrimitive('$image')
|
|
209
|
+
expect(result.success).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('getPrimitiveType', () => {
|
|
215
|
+
test('extracts type from simple primitive', () => {
|
|
216
|
+
expect(getPrimitiveType('$col')).toBe('$col')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('extracts type from primitive with props', () => {
|
|
220
|
+
expect(getPrimitiveType('$row(gap:lg)')).toBe('$row')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('returns null for non-primitive', () => {
|
|
224
|
+
expect(getPrimitiveType('components/button')).toBe(null)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('returns null for unknown primitive', () => {
|
|
228
|
+
expect(getPrimitiveType('$unknown')).toBe(null)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('isValidPrimitiveSyntax', () => {
|
|
233
|
+
test('returns true for valid primitives', () => {
|
|
234
|
+
expect(isValidPrimitiveSyntax('$col')).toBe(true)
|
|
235
|
+
expect(isValidPrimitiveSyntax('$row(gap:lg)')).toBe(true)
|
|
236
|
+
expect(isValidPrimitiveSyntax('$text(label)')).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('returns false for invalid primitives', () => {
|
|
240
|
+
expect(isValidPrimitiveSyntax('col')).toBe(false)
|
|
241
|
+
expect(isValidPrimitiveSyntax('$unknown')).toBe(false)
|
|
242
|
+
expect(isValidPrimitiveSyntax('$col(gap:invalid)')).toBe(false)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('isQuoted', () => {
|
|
247
|
+
test('returns true for double-quoted strings', () => {
|
|
248
|
+
expect(isQuoted('"hello"')).toBe(true)
|
|
249
|
+
expect(isQuoted('"Hello World"')).toBe(true)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('returns true for single-quoted strings', () => {
|
|
253
|
+
expect(isQuoted("'hello'")).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('returns false for unquoted strings', () => {
|
|
257
|
+
expect(isQuoted('hello')).toBe(false)
|
|
258
|
+
expect(isQuoted('label')).toBe(false)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('returns false for partially quoted strings', () => {
|
|
262
|
+
expect(isQuoted('"hello')).toBe(false)
|
|
263
|
+
expect(isQuoted('hello"')).toBe(false)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// src/primitives/parser.ts
|
|
2
|
+
// Parser for $name(props) primitive syntax
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
Primitive,
|
|
6
|
+
PrimitiveType,
|
|
7
|
+
ColPrimitive,
|
|
8
|
+
RowPrimitive,
|
|
9
|
+
BoxPrimitive,
|
|
10
|
+
SpacerPrimitive,
|
|
11
|
+
SlotPrimitive,
|
|
12
|
+
TextPrimitive,
|
|
13
|
+
IconPrimitive,
|
|
14
|
+
ImagePrimitive,
|
|
15
|
+
SpacingToken,
|
|
16
|
+
AlignToken,
|
|
17
|
+
BackgroundToken,
|
|
18
|
+
RadiusToken,
|
|
19
|
+
ColorToken,
|
|
20
|
+
SizeToken,
|
|
21
|
+
WeightToken,
|
|
22
|
+
FitToken,
|
|
23
|
+
} from './types'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Token validation sets (shadcn-compatible)
|
|
27
|
+
*/
|
|
28
|
+
const SPACING_TOKENS = new Set<SpacingToken>(['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'])
|
|
29
|
+
const ALIGN_TOKENS = new Set<AlignToken>(['start', 'center', 'end', 'stretch', 'between'])
|
|
30
|
+
const BG_TOKENS = new Set<BackgroundToken>([
|
|
31
|
+
'transparent', 'background', 'card', 'primary', 'secondary',
|
|
32
|
+
'muted', 'accent', 'destructive', 'input'
|
|
33
|
+
])
|
|
34
|
+
const RADIUS_TOKENS = new Set<RadiusToken>(['none', 'sm', 'md', 'lg', 'xl', 'full'])
|
|
35
|
+
const COLOR_TOKENS = new Set<ColorToken>([
|
|
36
|
+
'foreground', 'card-foreground',
|
|
37
|
+
'primary', 'primary-foreground',
|
|
38
|
+
'secondary', 'secondary-foreground',
|
|
39
|
+
'muted', 'muted-foreground',
|
|
40
|
+
'accent', 'accent-foreground',
|
|
41
|
+
'destructive', 'destructive-foreground',
|
|
42
|
+
'border', 'ring'
|
|
43
|
+
])
|
|
44
|
+
const SIZE_TOKENS = new Set<SizeToken>(['xs', 'sm', 'base', 'lg', 'xl', '2xl'])
|
|
45
|
+
const WEIGHT_TOKENS = new Set<WeightToken>(['normal', 'medium', 'semibold', 'bold'])
|
|
46
|
+
const FIT_TOKENS = new Set<FitToken>(['cover', 'contain', 'fill'])
|
|
47
|
+
|
|
48
|
+
const PRIMITIVE_NAMES = new Set<PrimitiveType>([
|
|
49
|
+
'$col', '$row', '$box', '$spacer', '$slot',
|
|
50
|
+
'$text', '$icon', '$image',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
export interface ParseError {
|
|
54
|
+
message: string
|
|
55
|
+
position?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ParseResultSuccess<T extends Primitive = Primitive> {
|
|
59
|
+
success: true
|
|
60
|
+
primitive: T
|
|
61
|
+
raw: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ParseResultFailure {
|
|
65
|
+
success: false
|
|
66
|
+
error: ParseError
|
|
67
|
+
raw: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ParseResult<T extends Primitive = Primitive> = ParseResultSuccess<T> | ParseResultFailure
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse a primitive string like "$col(gap:lg align:center)"
|
|
74
|
+
*/
|
|
75
|
+
export function parsePrimitive(input: string): ParseResult {
|
|
76
|
+
const raw = input.trim()
|
|
77
|
+
|
|
78
|
+
if (!raw.startsWith('$')) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: { message: 'Primitive must start with $' },
|
|
82
|
+
raw,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Extract name and props
|
|
87
|
+
const match = raw.match(/^(\$[a-z]+)(?:\(([^)]*)\))?$/)
|
|
88
|
+
if (!match) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
error: { message: `Invalid primitive syntax: ${raw}` },
|
|
92
|
+
raw,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const [, name, propsStr] = match
|
|
97
|
+
if (!PRIMITIVE_NAMES.has(name as PrimitiveType)) {
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: { message: `Unknown primitive: ${name}` },
|
|
101
|
+
raw,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const props = propsStr ? parseProps(propsStr) : {}
|
|
106
|
+
|
|
107
|
+
// Build typed primitive based on name
|
|
108
|
+
switch (name as PrimitiveType) {
|
|
109
|
+
case '$col':
|
|
110
|
+
return buildColPrimitive(props, raw)
|
|
111
|
+
case '$row':
|
|
112
|
+
return buildRowPrimitive(props, raw)
|
|
113
|
+
case '$box':
|
|
114
|
+
return buildBoxPrimitive(props, raw)
|
|
115
|
+
case '$spacer':
|
|
116
|
+
return buildSpacerPrimitive(props, raw)
|
|
117
|
+
case '$slot':
|
|
118
|
+
return buildSlotPrimitive(props, raw)
|
|
119
|
+
case '$text':
|
|
120
|
+
return buildTextPrimitive(props, raw)
|
|
121
|
+
case '$icon':
|
|
122
|
+
return buildIconPrimitive(props, raw)
|
|
123
|
+
case '$image':
|
|
124
|
+
return buildImagePrimitive(props, raw)
|
|
125
|
+
default:
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: { message: `Unhandled primitive: ${name}` },
|
|
129
|
+
raw,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse props string "gap:lg align:center" or "name size:sm"
|
|
136
|
+
* Returns a map of key:value pairs
|
|
137
|
+
* Positional args become $0, $1, etc.
|
|
138
|
+
*/
|
|
139
|
+
function parseProps(propsStr: string): Record<string, string> {
|
|
140
|
+
const props: Record<string, string> = {}
|
|
141
|
+
let positionalIndex = 0
|
|
142
|
+
|
|
143
|
+
// Handle quoted strings and key:value pairs
|
|
144
|
+
const tokens = tokenizeProps(propsStr)
|
|
145
|
+
|
|
146
|
+
for (const token of tokens) {
|
|
147
|
+
// Quoted tokens are always positional (even if they contain a colon)
|
|
148
|
+
const startsWithQuote = token.startsWith('"') || token.startsWith("'")
|
|
149
|
+
|
|
150
|
+
if (!startsWithQuote && token.includes(':')) {
|
|
151
|
+
const colonIndex = token.indexOf(':')
|
|
152
|
+
const key = token.slice(0, colonIndex)
|
|
153
|
+
const value = token.slice(colonIndex + 1)
|
|
154
|
+
props[key] = value
|
|
155
|
+
} else {
|
|
156
|
+
// Positional argument
|
|
157
|
+
props[`$${positionalIndex}`] = token
|
|
158
|
+
positionalIndex++
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return props
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Tokenize props string, respecting quoted values
|
|
167
|
+
*/
|
|
168
|
+
function tokenizeProps(propsStr: string): string[] {
|
|
169
|
+
const tokens: string[] = []
|
|
170
|
+
let current = ''
|
|
171
|
+
let inQuote = false
|
|
172
|
+
let quoteChar = ''
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < propsStr.length; i++) {
|
|
175
|
+
const char = propsStr[i]
|
|
176
|
+
|
|
177
|
+
if (!inQuote && (char === '"' || char === "'")) {
|
|
178
|
+
inQuote = true
|
|
179
|
+
quoteChar = char
|
|
180
|
+
current += char
|
|
181
|
+
} else if (inQuote && char === quoteChar) {
|
|
182
|
+
inQuote = false
|
|
183
|
+
current += char
|
|
184
|
+
quoteChar = ''
|
|
185
|
+
} else if (!inQuote && char === ' ') {
|
|
186
|
+
if (current) {
|
|
187
|
+
tokens.push(current)
|
|
188
|
+
current = ''
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
current += char
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (current) {
|
|
196
|
+
tokens.push(current)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return tokens
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Remove quotes from a value if present
|
|
204
|
+
*/
|
|
205
|
+
function unquote(value: string): string {
|
|
206
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
207
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
208
|
+
return value.slice(1, -1)
|
|
209
|
+
}
|
|
210
|
+
return value
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Check if a value is quoted (literal) vs bare (prop reference)
|
|
215
|
+
*/
|
|
216
|
+
export function isQuoted(value: string): boolean {
|
|
217
|
+
return (value.startsWith('"') && value.endsWith('"')) ||
|
|
218
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Builder functions for each primitive type
|
|
222
|
+
|
|
223
|
+
function buildColPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
224
|
+
const primitive: ColPrimitive = { type: '$col' }
|
|
225
|
+
|
|
226
|
+
if (props.gap) {
|
|
227
|
+
if (!SPACING_TOKENS.has(props.gap as SpacingToken)) {
|
|
228
|
+
return { success: false, error: { message: `Invalid gap token: ${props.gap}` }, raw }
|
|
229
|
+
}
|
|
230
|
+
primitive.gap = props.gap as SpacingToken
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (props.align) {
|
|
234
|
+
if (!ALIGN_TOKENS.has(props.align as AlignToken)) {
|
|
235
|
+
return { success: false, error: { message: `Invalid align token: ${props.align}` }, raw }
|
|
236
|
+
}
|
|
237
|
+
primitive.align = props.align as AlignToken
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (props.padding) {
|
|
241
|
+
if (!SPACING_TOKENS.has(props.padding as SpacingToken)) {
|
|
242
|
+
return { success: false, error: { message: `Invalid padding token: ${props.padding}` }, raw }
|
|
243
|
+
}
|
|
244
|
+
primitive.padding = props.padding as SpacingToken
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { success: true, primitive, raw }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildRowPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
251
|
+
const primitive: RowPrimitive = { type: '$row' }
|
|
252
|
+
|
|
253
|
+
if (props.gap) {
|
|
254
|
+
if (!SPACING_TOKENS.has(props.gap as SpacingToken)) {
|
|
255
|
+
return { success: false, error: { message: `Invalid gap token: ${props.gap}` }, raw }
|
|
256
|
+
}
|
|
257
|
+
primitive.gap = props.gap as SpacingToken
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (props.align) {
|
|
261
|
+
if (!ALIGN_TOKENS.has(props.align as AlignToken)) {
|
|
262
|
+
return { success: false, error: { message: `Invalid align token: ${props.align}` }, raw }
|
|
263
|
+
}
|
|
264
|
+
primitive.align = props.align as AlignToken
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (props.padding) {
|
|
268
|
+
if (!SPACING_TOKENS.has(props.padding as SpacingToken)) {
|
|
269
|
+
return { success: false, error: { message: `Invalid padding token: ${props.padding}` }, raw }
|
|
270
|
+
}
|
|
271
|
+
primitive.padding = props.padding as SpacingToken
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { success: true, primitive, raw }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildBoxPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
278
|
+
const primitive: BoxPrimitive = { type: '$box' }
|
|
279
|
+
|
|
280
|
+
if (props.padding) {
|
|
281
|
+
if (!SPACING_TOKENS.has(props.padding as SpacingToken)) {
|
|
282
|
+
return { success: false, error: { message: `Invalid padding token: ${props.padding}` }, raw }
|
|
283
|
+
}
|
|
284
|
+
primitive.padding = props.padding as SpacingToken
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (props.bg) {
|
|
288
|
+
if (!BG_TOKENS.has(props.bg as BackgroundToken)) {
|
|
289
|
+
return { success: false, error: { message: `Invalid bg token: ${props.bg}` }, raw }
|
|
290
|
+
}
|
|
291
|
+
primitive.bg = props.bg as BackgroundToken
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (props.radius) {
|
|
295
|
+
if (!RADIUS_TOKENS.has(props.radius as RadiusToken)) {
|
|
296
|
+
return { success: false, error: { message: `Invalid radius token: ${props.radius}` }, raw }
|
|
297
|
+
}
|
|
298
|
+
primitive.radius = props.radius as RadiusToken
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { success: true, primitive, raw }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function buildSpacerPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
305
|
+
const primitive: SpacerPrimitive = { type: '$spacer' }
|
|
306
|
+
|
|
307
|
+
// Spacer can have a positional size arg: $spacer(xl) or $spacer(size:xl)
|
|
308
|
+
const sizeValue = props['$0'] || props.size
|
|
309
|
+
if (sizeValue) {
|
|
310
|
+
if (!SPACING_TOKENS.has(sizeValue as SpacingToken)) {
|
|
311
|
+
return { success: false, error: { message: `Invalid size token: ${sizeValue}` }, raw }
|
|
312
|
+
}
|
|
313
|
+
primitive.size = sizeValue as SpacingToken
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { success: true, primitive, raw }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildSlotPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
320
|
+
// Slot requires a name: $slot(main) or $slot(name:main)
|
|
321
|
+
const nameValue = props['$0'] || props.name
|
|
322
|
+
if (!nameValue) {
|
|
323
|
+
return { success: false, error: { message: '$slot requires a name' }, raw }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const primitive: SlotPrimitive = {
|
|
327
|
+
type: '$slot',
|
|
328
|
+
name: unquote(nameValue),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { success: true, primitive, raw }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildTextPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
335
|
+
// Text requires content as first positional arg: $text(label) or $text("Hello")
|
|
336
|
+
const contentValue = props['$0'] || props.content
|
|
337
|
+
if (!contentValue) {
|
|
338
|
+
return { success: false, error: { message: '$text requires content' }, raw }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const primitive: TextPrimitive = {
|
|
342
|
+
type: '$text',
|
|
343
|
+
content: contentValue, // Keep as-is, renderer resolves prop refs
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (props.size) {
|
|
347
|
+
if (!SIZE_TOKENS.has(props.size as SizeToken)) {
|
|
348
|
+
return { success: false, error: { message: `Invalid size token: ${props.size}` }, raw }
|
|
349
|
+
}
|
|
350
|
+
primitive.size = props.size as SizeToken
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (props.weight) {
|
|
354
|
+
if (!WEIGHT_TOKENS.has(props.weight as WeightToken)) {
|
|
355
|
+
return { success: false, error: { message: `Invalid weight token: ${props.weight}` }, raw }
|
|
356
|
+
}
|
|
357
|
+
primitive.weight = props.weight as WeightToken
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (props.color) {
|
|
361
|
+
if (!COLOR_TOKENS.has(props.color as ColorToken)) {
|
|
362
|
+
return { success: false, error: { message: `Invalid color token: ${props.color}` }, raw }
|
|
363
|
+
}
|
|
364
|
+
primitive.color = props.color as ColorToken
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { success: true, primitive, raw }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildIconPrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
371
|
+
// Icon requires name: $icon(name:check) or $icon(iconProp)
|
|
372
|
+
const nameValue = props.name || props['$0']
|
|
373
|
+
if (!nameValue) {
|
|
374
|
+
return { success: false, error: { message: '$icon requires name' }, raw }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const primitive: IconPrimitive = {
|
|
378
|
+
type: '$icon',
|
|
379
|
+
name: nameValue, // Keep as-is, renderer resolves prop refs
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (props.size) {
|
|
383
|
+
if (!SIZE_TOKENS.has(props.size as SizeToken)) {
|
|
384
|
+
return { success: false, error: { message: `Invalid size token: ${props.size}` }, raw }
|
|
385
|
+
}
|
|
386
|
+
primitive.size = props.size as SizeToken
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (props.color) {
|
|
390
|
+
if (!COLOR_TOKENS.has(props.color as ColorToken)) {
|
|
391
|
+
return { success: false, error: { message: `Invalid color token: ${props.color}` }, raw }
|
|
392
|
+
}
|
|
393
|
+
primitive.color = props.color as ColorToken
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { success: true, primitive, raw }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildImagePrimitive(props: Record<string, string>, raw: string): ParseResult {
|
|
400
|
+
// Image requires src: $image(src:url) or $image(srcProp)
|
|
401
|
+
const srcValue = props.src || props['$0']
|
|
402
|
+
if (!srcValue) {
|
|
403
|
+
return { success: false, error: { message: '$image requires src' }, raw }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const primitive: ImagePrimitive = {
|
|
407
|
+
type: '$image',
|
|
408
|
+
src: srcValue, // Keep as-is, renderer resolves prop refs
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (props.alt) {
|
|
412
|
+
primitive.alt = unquote(props.alt)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (props.fit) {
|
|
416
|
+
if (!FIT_TOKENS.has(props.fit as FitToken)) {
|
|
417
|
+
return { success: false, error: { message: `Invalid fit token: ${props.fit}` }, raw }
|
|
418
|
+
}
|
|
419
|
+
primitive.fit = props.fit as FitToken
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { success: true, primitive, raw }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Extract primitive type from a primitive string
|
|
427
|
+
*/
|
|
428
|
+
export function getPrimitiveType(input: string): PrimitiveType | null {
|
|
429
|
+
const match = input.match(/^(\$[a-z]+)/)
|
|
430
|
+
if (match && PRIMITIVE_NAMES.has(match[1] as PrimitiveType)) {
|
|
431
|
+
return match[1] as PrimitiveType
|
|
432
|
+
}
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Check if a string is a valid primitive syntax
|
|
438
|
+
*/
|
|
439
|
+
export function isValidPrimitiveSyntax(input: string): boolean {
|
|
440
|
+
const result = parsePrimitive(input)
|
|
441
|
+
return result.success
|
|
442
|
+
}
|