prev-cli 0.24.18 → 0.24.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +5 -2
  2. package/src/jsx/adapters/html.test.ts +191 -0
  3. package/src/jsx/adapters/html.ts +404 -0
  4. package/src/jsx/adapters/react.test.ts +172 -0
  5. package/src/jsx/adapters/react.tsx +346 -0
  6. package/src/jsx/define-component.ts +129 -0
  7. package/src/jsx/index.ts +47 -0
  8. package/src/jsx/jsx-runtime.test.ts +63 -0
  9. package/src/jsx/jsx-runtime.ts +117 -0
  10. package/src/jsx/migrate.test.ts +72 -0
  11. package/src/jsx/migrate.ts +451 -0
  12. package/src/jsx/schemas/index.ts +4 -0
  13. package/src/jsx/schemas/primitives.ts +107 -0
  14. package/src/jsx/schemas/tokens.ts +60 -0
  15. package/src/jsx/validation.ts +77 -0
  16. package/src/jsx/vnode.ts +159 -0
  17. package/src/primitives/index.ts +8 -0
  18. package/src/primitives/migrate.test.ts +317 -0
  19. package/src/primitives/migrate.ts +364 -0
  20. package/src/primitives/parser.test.ts +265 -0
  21. package/src/primitives/parser.ts +442 -0
  22. package/src/primitives/template-parser.test.ts +297 -0
  23. package/src/primitives/template-parser.ts +374 -0
  24. package/src/primitives/template-renderer.test.ts +359 -0
  25. package/src/primitives/template-renderer.ts +497 -0
  26. package/src/primitives/tokens.css +82 -0
  27. package/src/primitives/types.ts +248 -0
  28. package/src/tokens/defaults.test.ts +137 -0
  29. package/src/tokens/defaults.ts +77 -0
  30. package/src/tokens/defaults.yaml +76 -0
  31. package/src/tokens/resolver.test.ts +229 -0
  32. package/src/tokens/resolver.ts +173 -0
  33. package/src/tokens/utils.test.ts +172 -0
  34. package/src/tokens/utils.ts +104 -0
  35. package/src/tokens/validation.test.ts +118 -0
  36. package/src/tokens/validation.ts +226 -0
@@ -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
+ }