prev-cli 0.24.18 → 0.24.20
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 +11 -0
- 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,118 @@
|
|
|
1
|
+
// src/tokens/validation.test.ts
|
|
2
|
+
import { describe, test, expect } from 'bun:test'
|
|
3
|
+
import { validateToken, ValidationError, isValidToken } from './validation'
|
|
4
|
+
|
|
5
|
+
describe('validateToken', () => {
|
|
6
|
+
// Colors
|
|
7
|
+
test('valid color token passes', () => {
|
|
8
|
+
expect(() => validateToken('colors', 'primary')).not.toThrow()
|
|
9
|
+
expect(() => validateToken('colors', 'destructive')).not.toThrow()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('invalid color token throws ValidationError', () => {
|
|
13
|
+
expect(() => validateToken('colors', 'purpl')).toThrow(ValidationError)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('error includes "did you mean" suggestions for close matches', () => {
|
|
17
|
+
try {
|
|
18
|
+
validateToken('colors', 'primar')
|
|
19
|
+
throw new Error('Should have thrown')
|
|
20
|
+
} catch (e) {
|
|
21
|
+
expect(e).toBeInstanceOf(ValidationError)
|
|
22
|
+
expect((e as Error).message).toContain("Unknown color 'primar'")
|
|
23
|
+
expect((e as Error).message).toContain('Did you mean')
|
|
24
|
+
expect((e as Error).message).toContain('primary')
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('error lists available tokens when no close matches', () => {
|
|
29
|
+
try {
|
|
30
|
+
validateToken('colors', 'xyz')
|
|
31
|
+
throw new Error('Should have thrown')
|
|
32
|
+
} catch (e) {
|
|
33
|
+
expect((e as Error).message).toContain('Available colors:')
|
|
34
|
+
expect((e as Error).message).toContain('primary')
|
|
35
|
+
expect((e as Error).message).toContain('secondary')
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Spacing
|
|
40
|
+
test('valid spacing token passes', () => {
|
|
41
|
+
expect(() => validateToken('spacing', 'lg')).not.toThrow()
|
|
42
|
+
expect(() => validateToken('spacing', 'md')).not.toThrow()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('invalid spacing throws with suggestions', () => {
|
|
46
|
+
try {
|
|
47
|
+
validateToken('spacing', 'lrg')
|
|
48
|
+
throw new Error('Should have thrown')
|
|
49
|
+
} catch (e) {
|
|
50
|
+
expect((e as Error).message).toContain('lg') // suggests 'lg'
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Typography
|
|
55
|
+
test('validates nested typography.sizes', () => {
|
|
56
|
+
expect(() => validateToken('typography.sizes', 'base')).not.toThrow()
|
|
57
|
+
expect(() => validateToken('typography.sizes', 'huge')).toThrow(ValidationError)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('validates nested typography.weights', () => {
|
|
61
|
+
expect(() => validateToken('typography.weights', 'bold')).not.toThrow()
|
|
62
|
+
expect(() => validateToken('typography.weights', 'thin')).toThrow(ValidationError)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Backgrounds
|
|
66
|
+
test('validates background tokens', () => {
|
|
67
|
+
expect(() => validateToken('backgrounds', 'muted')).not.toThrow()
|
|
68
|
+
expect(() => validateToken('backgrounds', 'dark')).toThrow(ValidationError)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Radius
|
|
72
|
+
test('validates radius tokens', () => {
|
|
73
|
+
expect(() => validateToken('radius', 'full')).not.toThrow()
|
|
74
|
+
expect(() => validateToken('radius', 'round')).toThrow(ValidationError)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Shadows
|
|
78
|
+
test('validates shadow tokens', () => {
|
|
79
|
+
expect(() => validateToken('shadows', 'xl')).not.toThrow()
|
|
80
|
+
expect(() => validateToken('shadows', 'huge')).toThrow(ValidationError)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Edge cases
|
|
84
|
+
test('invalid category throws', () => {
|
|
85
|
+
expect(() => validateToken('invalid' as any, 'foo')).toThrow(/Unknown category/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('empty token name throws', () => {
|
|
89
|
+
expect(() => validateToken('colors', '')).toThrow(/Token name cannot be empty/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('whitespace-only token name throws', () => {
|
|
93
|
+
expect(() => validateToken('colors', ' ')).toThrow(/Token name cannot be empty/)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('isValidToken', () => {
|
|
98
|
+
test('returns true for valid token', () => {
|
|
99
|
+
expect(isValidToken('colors', 'primary')).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('returns false for invalid token', () => {
|
|
103
|
+
expect(isValidToken('colors', 'invalid')).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('throws for invalid category', () => {
|
|
107
|
+
expect(() => isValidToken('invalid' as any, 'foo')).toThrow(/Unknown category/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('works with nested categories', () => {
|
|
111
|
+
expect(isValidToken('typography.sizes', 'base')).toBe(true)
|
|
112
|
+
expect(isValidToken('typography.sizes', 'huge')).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('throws for empty token name', () => {
|
|
116
|
+
expect(() => isValidToken('colors', '')).toThrow(/Token name cannot be empty/)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/tokens/validation.ts
|
|
2
|
+
// Token validation with helpful "did you mean?" suggestions
|
|
3
|
+
|
|
4
|
+
import { resolveTokens, type TokensConfig } from './resolver'
|
|
5
|
+
import { levenshtein } from './utils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Error thrown when a token validation fails.
|
|
9
|
+
* Includes helpful suggestions for similar valid tokens.
|
|
10
|
+
*/
|
|
11
|
+
export class ValidationError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string,
|
|
14
|
+
public readonly category: string,
|
|
15
|
+
public readonly invalidToken: string,
|
|
16
|
+
public readonly suggestions: string[]
|
|
17
|
+
) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'ValidationError'
|
|
20
|
+
// Ensure proper prototype chain for instanceof checks
|
|
21
|
+
Object.setPrototypeOf(this, ValidationError.prototype)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Valid category names that can be used with validateToken.
|
|
27
|
+
* Supports dot notation for nested categories like "typography.sizes"
|
|
28
|
+
*/
|
|
29
|
+
export type TokenCategory =
|
|
30
|
+
| 'colors'
|
|
31
|
+
| 'backgrounds'
|
|
32
|
+
| 'spacing'
|
|
33
|
+
| 'typography.sizes'
|
|
34
|
+
| 'typography.weights'
|
|
35
|
+
| 'radius'
|
|
36
|
+
| 'shadows'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get a human-readable singular name for a category
|
|
40
|
+
*/
|
|
41
|
+
function getCategoryLabel(category: string): string {
|
|
42
|
+
const labels: Record<string, string> = {
|
|
43
|
+
'colors': 'color',
|
|
44
|
+
'backgrounds': 'background',
|
|
45
|
+
'spacing': 'spacing',
|
|
46
|
+
'typography.sizes': 'typography size',
|
|
47
|
+
'typography.weights': 'typography weight',
|
|
48
|
+
'radius': 'radius',
|
|
49
|
+
'shadows': 'shadow'
|
|
50
|
+
}
|
|
51
|
+
return labels[category] || category
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get a human-readable plural name for a category
|
|
56
|
+
*/
|
|
57
|
+
function getCategoryPluralLabel(category: string): string {
|
|
58
|
+
const labels: Record<string, string> = {
|
|
59
|
+
'colors': 'colors',
|
|
60
|
+
'backgrounds': 'backgrounds',
|
|
61
|
+
'spacing': 'spacing values',
|
|
62
|
+
'typography.sizes': 'typography sizes',
|
|
63
|
+
'typography.weights': 'typography weights',
|
|
64
|
+
'radius': 'radius values',
|
|
65
|
+
'shadows': 'shadows'
|
|
66
|
+
}
|
|
67
|
+
return labels[category] || category
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get available tokens for a given category from the resolved config.
|
|
72
|
+
* Supports dot notation for nested paths like "typography.sizes"
|
|
73
|
+
*
|
|
74
|
+
* @param config - The resolved tokens configuration
|
|
75
|
+
* @param category - The category path (e.g., "colors" or "typography.sizes")
|
|
76
|
+
* @returns Array of valid token names, or null if category is invalid
|
|
77
|
+
*/
|
|
78
|
+
function getTokensForCategory(
|
|
79
|
+
config: TokensConfig,
|
|
80
|
+
category: string
|
|
81
|
+
): string[] | null {
|
|
82
|
+
const parts = category.split('.')
|
|
83
|
+
|
|
84
|
+
// Navigate to the nested object
|
|
85
|
+
let current: unknown = config
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
if (current && typeof current === 'object' && part in current) {
|
|
88
|
+
current = (current as Record<string, unknown>)[part]
|
|
89
|
+
} else {
|
|
90
|
+
return null // Invalid path
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Current should now be a Record<string, something>
|
|
95
|
+
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
|
96
|
+
return Object.keys(current as Record<string, unknown>)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find close matches for an invalid token using Levenshtein distance.
|
|
104
|
+
* Returns tokens within distance 3 of the input, sorted by distance.
|
|
105
|
+
*
|
|
106
|
+
* @param input - The invalid token name
|
|
107
|
+
* @param validTokens - Array of valid token names
|
|
108
|
+
* @param maxDistance - Maximum edit distance for suggestions (default: 3)
|
|
109
|
+
* @returns Array of close matches sorted by distance
|
|
110
|
+
*/
|
|
111
|
+
function findClosestMatches(
|
|
112
|
+
input: string,
|
|
113
|
+
validTokens: string[],
|
|
114
|
+
maxDistance: number = 3
|
|
115
|
+
): string[] {
|
|
116
|
+
const matches: Array<{ token: string; distance: number }> = []
|
|
117
|
+
|
|
118
|
+
for (const token of validTokens) {
|
|
119
|
+
const distance = levenshtein(input.toLowerCase(), token.toLowerCase())
|
|
120
|
+
if (distance <= maxDistance) {
|
|
121
|
+
matches.push({ token, distance })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Sort by distance (closest first)
|
|
126
|
+
matches.sort((a, b) => a.distance - b.distance)
|
|
127
|
+
|
|
128
|
+
return matches.slice(0, 3).map(m => m.token)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Format error message with suggestions or available tokens.
|
|
133
|
+
*
|
|
134
|
+
* @param category - The token category
|
|
135
|
+
* @param invalidToken - The invalid token name
|
|
136
|
+
* @param suggestions - Close matches (if any)
|
|
137
|
+
* @param availableTokens - All valid tokens for the category
|
|
138
|
+
* @returns Formatted error message
|
|
139
|
+
*/
|
|
140
|
+
function formatErrorMessage(
|
|
141
|
+
category: string,
|
|
142
|
+
invalidToken: string,
|
|
143
|
+
suggestions: string[],
|
|
144
|
+
availableTokens: string[]
|
|
145
|
+
): string {
|
|
146
|
+
const label = getCategoryLabel(category)
|
|
147
|
+
const pluralLabel = getCategoryPluralLabel(category)
|
|
148
|
+
|
|
149
|
+
const lines: string[] = [
|
|
150
|
+
`Unknown ${label} '${invalidToken}'`
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
if (suggestions.length > 0) {
|
|
154
|
+
lines.push('')
|
|
155
|
+
lines.push(`Did you mean: ${suggestions.join(', ')}?`)
|
|
156
|
+
} else {
|
|
157
|
+
lines.push('')
|
|
158
|
+
const displayTokens = availableTokens.slice(0, 10)
|
|
159
|
+
const suffix = availableTokens.length > 10 ? ` (and ${availableTokens.length - 10} more)` : ''
|
|
160
|
+
lines.push(`Available ${pluralLabel}: ${displayTokens.join(', ')}${suffix}`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return lines.join('\n')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate that a token name is valid for a given category.
|
|
168
|
+
* Throws a ValidationError with helpful suggestions if invalid.
|
|
169
|
+
*
|
|
170
|
+
* @param category - The token category (e.g., "colors", "typography.sizes")
|
|
171
|
+
* @param tokenName - The token name to validate
|
|
172
|
+
* @param config - Optional pre-resolved tokens config (defaults to resolveTokens())
|
|
173
|
+
* @throws ValidationError if the token is invalid
|
|
174
|
+
* @throws Error if the category is invalid
|
|
175
|
+
*/
|
|
176
|
+
export function validateToken(
|
|
177
|
+
category: TokenCategory,
|
|
178
|
+
tokenName: string,
|
|
179
|
+
config?: TokensConfig
|
|
180
|
+
): void {
|
|
181
|
+
if (!tokenName || !tokenName.trim()) {
|
|
182
|
+
throw new Error('Token name cannot be empty')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const resolvedConfig = config ?? resolveTokens()
|
|
186
|
+
|
|
187
|
+
const validTokens = getTokensForCategory(resolvedConfig, category)
|
|
188
|
+
|
|
189
|
+
if (validTokens === null) {
|
|
190
|
+
throw new Error(`Unknown category '${category}'. Valid categories: colors, backgrounds, spacing, typography.sizes, typography.weights, radius, shadows`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (validTokens.includes(tokenName)) {
|
|
194
|
+
return // Token is valid
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Token is invalid - build helpful error message
|
|
198
|
+
const suggestions = findClosestMatches(tokenName, validTokens)
|
|
199
|
+
const message = formatErrorMessage(category, tokenName, suggestions, validTokens)
|
|
200
|
+
|
|
201
|
+
throw new ValidationError(message, category, tokenName, suggestions)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if a token is valid without throwing.
|
|
206
|
+
*
|
|
207
|
+
* @param category - The token category
|
|
208
|
+
* @param tokenName - The token name to check
|
|
209
|
+
* @param config - Optional pre-resolved tokens config
|
|
210
|
+
* @returns true if valid, false if invalid
|
|
211
|
+
*/
|
|
212
|
+
export function isValidToken(
|
|
213
|
+
category: TokenCategory,
|
|
214
|
+
tokenName: string,
|
|
215
|
+
config?: TokensConfig
|
|
216
|
+
): boolean {
|
|
217
|
+
try {
|
|
218
|
+
validateToken(category, tokenName, config)
|
|
219
|
+
return true
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (e instanceof ValidationError) {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
throw e // Re-throw category errors and other unexpected errors
|
|
225
|
+
}
|
|
226
|
+
}
|