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,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
+ }