@uniweb/build 0.8.13 → 0.8.15

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.
@@ -1,584 +0,0 @@
1
- /**
2
- * Theme Processor
3
- *
4
- * Reads, validates, and processes theme configuration from theme.yml,
5
- * merges with foundation defaults, and produces a complete theme config
6
- * ready for CSS generation.
7
- *
8
- * @module @uniweb/build/theme/processor
9
- */
10
-
11
- import { isValidColor, generatePalettes } from './shade-generator.js'
12
- import { getDefaultColors, getDefaultContextTokens } from './css-generator.js'
13
-
14
- /**
15
- * Named neutral presets mapping to Tailwind gray families
16
- */
17
- const NEUTRAL_PRESETS = {
18
- stone: '#78716c',
19
- zinc: '#71717a',
20
- gray: '#6b7280',
21
- slate: '#64748b',
22
- neutral: '#737373',
23
- }
24
-
25
- /**
26
- * Default inline text styles (content-author markdown: [text]{accent})
27
- * These reference semantic tokens so they adapt to context automatically
28
- */
29
- const DEFAULT_INLINE = {
30
- accent: {
31
- color: 'var(--link)',
32
- 'font-weight': '600',
33
- },
34
- muted: {
35
- color: 'var(--subtle)',
36
- },
37
- }
38
-
39
- /**
40
- * Default appearance configuration
41
- */
42
- const DEFAULT_APPEARANCE = {
43
- default: 'light', // Default color scheme
44
- allowToggle: false, // Whether to show scheme toggle
45
- respectSystemPreference: true, // Honor prefers-color-scheme
46
- }
47
-
48
- /**
49
- * Default font configuration
50
- */
51
- const DEFAULT_FONTS = {
52
- body: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
53
- heading: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
54
- mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
55
- }
56
-
57
- /**
58
- * Default code block theme configuration
59
- * Uses Shiki CSS variable names for compatibility
60
- * These values are NOT converted to CSS here - the kit's Code component
61
- * injects them at runtime only when code blocks are used (tree-shaking)
62
- */
63
- const DEFAULT_CODE_THEME = {
64
- // Background and foreground
65
- background: '#1e1e2e', // Dark editor background
66
- foreground: '#cdd6f4', // Default text color
67
-
68
- // Syntax highlighting colors (Shiki token variables)
69
- keyword: '#cba6f7', // Purple - keywords (if, else, function)
70
- string: '#a6e3a1', // Green - strings
71
- number: '#fab387', // Orange - numbers
72
- comment: '#6c7086', // Gray - comments
73
- function: '#89b4fa', // Blue - function names
74
- variable: '#f5e0dc', // Light pink - variables
75
- operator: '#89dceb', // Cyan - operators
76
- punctuation: '#9399b2', // Gray - punctuation
77
- type: '#f9e2af', // Yellow - types
78
- constant: '#f38ba8', // Red - constants
79
- property: '#94e2d5', // Teal - properties
80
- tag: '#89b4fa', // Blue - HTML/JSX tags
81
- attribute: '#f9e2af', // Yellow - attributes
82
-
83
- // UI elements
84
- lineNumber: '#6c7086', // Line number color
85
- selection: '#45475a', // Selection background
86
- }
87
-
88
- /**
89
- * Valid shade levels for palette references
90
- */
91
- const SHADE_LEVELS = new Set([50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950])
92
-
93
- /**
94
- * Resolve context token values to valid CSS.
95
- *
96
- * Content authors write palette references as bare names: `primary-500`,
97
- * `neutral-200`. This is the natural syntax in theme.yml. The processor
98
- * resolves these to `var(--primary-500)` etc. Plain CSS values (hex, var(),
99
- * named colors) pass through as-is — that's the escape hatch.
100
- *
101
- * @param {string} value - The token value from theme.yml
102
- * @returns {string} Valid CSS value
103
- */
104
- function normalizePaletteRef(value) {
105
- if (typeof value !== 'string') return value
106
-
107
- // Already a CSS function (var(), rgb(), etc.) — pass through
108
- if (value.includes('(')) return value
109
-
110
- // Hex color — pass through
111
- if (value.startsWith('#')) return value
112
-
113
- // Bare palette reference: "primary-500", "--primary-500"
114
- const bare = value.replace(/^-{0,2}/, '')
115
- const match = bare.match(/^([a-z][a-z0-9]*)-(\d+)$/)
116
-
117
- if (match) {
118
- const shade = parseInt(match[2], 10)
119
- if (SHADE_LEVELS.has(shade)) {
120
- return `var(--${bare})`
121
- }
122
- }
123
-
124
- return value
125
- }
126
-
127
- /**
128
- * Validate color configuration
129
- *
130
- * @param {Object} colors - Color configuration object
131
- * @returns {{ valid: boolean, errors: string[] }}
132
- */
133
- function validateColors(colors) {
134
- const errors = []
135
-
136
- if (!colors || typeof colors !== 'object') {
137
- return { valid: true, errors } // No colors is valid (use defaults)
138
- }
139
-
140
- for (const [name, value] of Object.entries(colors)) {
141
- // Skip pre-defined palette objects
142
- if (typeof value === 'object' && value !== null) {
143
- continue
144
- }
145
-
146
- if (typeof value !== 'string') {
147
- errors.push(`Color "${name}" must be a string or shade object, got ${typeof value}`)
148
- continue
149
- }
150
-
151
- // Accept neutral preset names (stone, zinc, gray, slate, neutral)
152
- if (name === 'neutral' && NEUTRAL_PRESETS[value]) {
153
- continue
154
- }
155
-
156
- if (!isValidColor(value)) {
157
- errors.push(`Color "${name}" has invalid value: ${value}`)
158
- }
159
- }
160
-
161
- return { valid: errors.length === 0, errors }
162
- }
163
-
164
- /**
165
- * Validate context configuration
166
- *
167
- * @param {Object} contexts - Context configuration object
168
- * @returns {{ valid: boolean, errors: string[] }}
169
- */
170
- function validateContexts(contexts) {
171
- const errors = []
172
-
173
- if (!contexts || typeof contexts !== 'object') {
174
- return { valid: true, errors }
175
- }
176
-
177
- const validContexts = ['light', 'medium', 'dark']
178
-
179
- for (const [context, tokens] of Object.entries(contexts)) {
180
- if (!validContexts.includes(context)) {
181
- errors.push(`Unknown context "${context}". Valid contexts: ${validContexts.join(', ')}`)
182
- continue
183
- }
184
-
185
- if (typeof tokens !== 'object' || tokens === null) {
186
- errors.push(`Context "${context}" must be an object`)
187
- }
188
- }
189
-
190
- return { valid: errors.length === 0, errors }
191
- }
192
-
193
- /**
194
- * Validate font configuration
195
- *
196
- * @param {Object} fonts - Font configuration object
197
- * @returns {{ valid: boolean, errors: string[] }}
198
- */
199
- function validateFonts(fonts) {
200
- const errors = []
201
-
202
- if (!fonts || typeof fonts !== 'object') {
203
- return { valid: true, errors }
204
- }
205
-
206
- // Validate imports
207
- if (fonts.import !== undefined) {
208
- if (!Array.isArray(fonts.import)) {
209
- errors.push('fonts.import must be an array')
210
- } else {
211
- for (const [index, item] of fonts.import.entries()) {
212
- if (typeof item !== 'object' || !item.url) {
213
- errors.push(`fonts.import[${index}] must have a "url" property`)
214
- }
215
- }
216
- }
217
- }
218
-
219
- return { valid: errors.length === 0, errors }
220
- }
221
-
222
- /**
223
- * Validate appearance configuration
224
- *
225
- * @param {Object} appearance - Appearance configuration
226
- * @returns {{ valid: boolean, errors: string[] }}
227
- */
228
- function validateAppearance(appearance) {
229
- const errors = []
230
-
231
- if (!appearance || typeof appearance !== 'object') {
232
- // Simple string value (e.g., appearance: light)
233
- if (typeof appearance === 'string') {
234
- if (!['light', 'dark', 'system'].includes(appearance)) {
235
- errors.push(`Invalid appearance value: ${appearance}. Must be "light", "dark", or "system"`)
236
- }
237
- }
238
- return { valid: errors.length === 0, errors }
239
- }
240
-
241
- if (appearance.default && !['light', 'dark', 'system'].includes(appearance.default)) {
242
- errors.push(`Invalid appearance.default: ${appearance.default}`)
243
- }
244
-
245
- if (appearance.schemes !== undefined) {
246
- if (!Array.isArray(appearance.schemes)) {
247
- errors.push('appearance.schemes must be an array')
248
- } else {
249
- const validSchemes = ['light', 'dark']
250
- for (const scheme of appearance.schemes) {
251
- if (!validSchemes.includes(scheme)) {
252
- errors.push(`Invalid scheme: ${scheme}. Valid schemes: ${validSchemes.join(', ')}`)
253
- }
254
- }
255
- }
256
- }
257
-
258
- return { valid: errors.length === 0, errors }
259
- }
260
-
261
- /**
262
- * Validate code block theme configuration
263
- *
264
- * @param {Object} code - Code theme configuration
265
- * @returns {{ valid: boolean, errors: string[] }}
266
- */
267
- function validateCodeTheme(code) {
268
- const errors = []
269
-
270
- if (!code || typeof code !== 'object') {
271
- return { valid: true, errors } // No code config is valid (use defaults)
272
- }
273
-
274
- // Validate color values
275
- for (const [name, value] of Object.entries(code)) {
276
- if (typeof value !== 'string') {
277
- errors.push(`code.${name} must be a string, got ${typeof value}`)
278
- continue
279
- }
280
-
281
- // Basic color format check (hex, rgb, hsl, or color name)
282
- if (!isValidColor(value)) {
283
- errors.push(`code.${name} has invalid color value: ${value}`)
284
- }
285
- }
286
-
287
- return { valid: errors.length === 0, errors }
288
- }
289
-
290
- /**
291
- * Validate foundation variables configuration
292
- *
293
- * @param {Object} vars - Foundation variables
294
- * @returns {{ valid: boolean, errors: string[] }}
295
- */
296
- function validateFoundationVars(vars) {
297
- const errors = []
298
-
299
- if (!vars || typeof vars !== 'object') {
300
- return { valid: true, errors }
301
- }
302
-
303
- for (const [name, config] of Object.entries(vars)) {
304
- // Variable name validation
305
- if (!/^[a-z][a-z0-9-]*$/i.test(name)) {
306
- errors.push(`Invalid variable name "${name}". Use lowercase letters, numbers, and hyphens.`)
307
- }
308
-
309
- // Config validation
310
- if (typeof config !== 'object' && typeof config !== 'string' && typeof config !== 'number') {
311
- errors.push(`Variable "${name}" must have a string, number, or config object value`)
312
- }
313
- }
314
-
315
- return { valid: errors.length === 0, errors }
316
- }
317
-
318
- /**
319
- * Validate complete theme configuration
320
- *
321
- * @param {Object} config - Raw theme configuration
322
- * @returns {{ valid: boolean, errors: string[] }}
323
- */
324
- export function validateThemeConfig(config) {
325
- const allErrors = []
326
-
327
- if (!config || typeof config !== 'object') {
328
- return { valid: true, errors: [] } // Empty config is valid (use all defaults)
329
- }
330
-
331
- const colorValidation = validateColors(config.colors)
332
- const contextValidation = validateContexts(config.contexts)
333
- const fontValidation = validateFonts(config.fonts)
334
- const appearanceValidation = validateAppearance(config.appearance)
335
- const codeValidation = validateCodeTheme(config.code)
336
-
337
- allErrors.push(...colorValidation.errors)
338
- allErrors.push(...contextValidation.errors)
339
- allErrors.push(...fontValidation.errors)
340
- allErrors.push(...appearanceValidation.errors)
341
- allErrors.push(...codeValidation.errors)
342
-
343
- return {
344
- valid: allErrors.length === 0,
345
- errors: allErrors,
346
- }
347
- }
348
-
349
- /**
350
- * Normalize appearance configuration
351
- *
352
- * @param {string|Object} appearance - Raw appearance config
353
- * @returns {Object} Normalized appearance config
354
- */
355
- function normalizeAppearance(appearance) {
356
- if (!appearance) {
357
- return { ...DEFAULT_APPEARANCE }
358
- }
359
-
360
- // Simple string value: "light", "dark", or "system"
361
- if (typeof appearance === 'string') {
362
- return {
363
- default: appearance,
364
- allowToggle: false,
365
- respectSystemPreference: appearance === 'system',
366
- }
367
- }
368
-
369
- return {
370
- ...DEFAULT_APPEARANCE,
371
- ...appearance,
372
- }
373
- }
374
-
375
- /**
376
- * Merge foundation variables with site overrides
377
- *
378
- * @param {Object} foundationVars - Variables from foundation vars.js
379
- * @param {Object} siteVars - Site-level variable overrides
380
- * @returns {Object} Merged variables
381
- */
382
- function mergeFoundationVars(foundationVars = {}, siteVars = {}) {
383
- const merged = {}
384
-
385
- // Start with foundation defaults
386
- for (const [name, config] of Object.entries(foundationVars)) {
387
- merged[name] = typeof config === 'object' ? { ...config } : { default: config }
388
- }
389
-
390
- // Apply site overrides
391
- for (const [name, value] of Object.entries(siteVars)) {
392
- if (merged[name]) {
393
- // Override the default value
394
- merged[name].default = value
395
- } else {
396
- // New variable from site
397
- merged[name] = { default: value }
398
- }
399
- }
400
-
401
- return merged
402
- }
403
-
404
- /**
405
- * Process raw theme configuration into a complete, validated config
406
- *
407
- * @param {Object} rawConfig - Raw theme.yml content
408
- * @param {Object} options - Processing options
409
- * @param {Object} options.foundationVars - Foundation variables from vars.js
410
- * @param {boolean} options.strict - Throw on validation errors (default: false)
411
- * @returns {{ config: Object, errors: string[], warnings: string[] }}
412
- */
413
- export function processTheme(rawConfig = {}, options = {}) {
414
- const { foundationVars = {}, strict = false } = options
415
- const errors = []
416
- const warnings = []
417
-
418
- // Validate raw config
419
- const validation = validateThemeConfig(rawConfig)
420
- if (!validation.valid) {
421
- errors.push(...validation.errors)
422
- if (strict) {
423
- throw new Error(`Theme configuration errors:\n${errors.join('\n')}`)
424
- }
425
- }
426
-
427
- // Process colors
428
- const defaultColors = getDefaultColors()
429
- const rawColors = { ...(rawConfig.colors || {}) }
430
-
431
- // Resolve named neutral presets to hex values
432
- if (typeof rawColors.neutral === 'string' && NEUTRAL_PRESETS[rawColors.neutral]) {
433
- rawColors.neutral = NEUTRAL_PRESETS[rawColors.neutral]
434
- }
435
-
436
- // Filter to only valid colors (skip invalid ones in non-strict mode)
437
- const validColors = {}
438
- for (const [name, value] of Object.entries({ ...defaultColors, ...rawColors })) {
439
- // Skip objects (pre-defined palettes) or invalid color strings
440
- if (typeof value === 'object' && value !== null) {
441
- validColors[name] = value
442
- } else if (isValidColor(value)) {
443
- validColors[name] = value
444
- }
445
- // Invalid colors are skipped (error already recorded during validation)
446
- }
447
-
448
- const colors = validColors
449
-
450
- // Generate color palettes (shades 50-950 for each color)
451
- // This is used by the Theme class for runtime color access
452
- const palettes = generatePalettes(colors)
453
-
454
- // Warn if required colors are missing
455
- if (!rawConfig.colors?.primary) {
456
- warnings.push('No primary color specified, using default blue (#3b82f6)')
457
- }
458
- if (!rawConfig.colors?.neutral) {
459
- warnings.push('No neutral color specified, using default stone (#78716c)')
460
- }
461
-
462
- // Process contexts (resolve bare palette refs like "primary-500" to var())
463
- const defaultContexts = getDefaultContextTokens()
464
- const rawContexts = rawConfig.contexts || {}
465
- const contexts = {}
466
-
467
- for (const name of ['light', 'medium', 'dark']) {
468
- const overrides = rawContexts[name] || {}
469
- const normalized = {}
470
-
471
- for (const [token, value] of Object.entries(overrides)) {
472
- normalized[token] = normalizePaletteRef(value)
473
- }
474
-
475
- contexts[name] = { ...defaultContexts[name], ...normalized }
476
- }
477
-
478
- // Process fonts
479
- const fonts = {
480
- ...DEFAULT_FONTS,
481
- ...(rawConfig.fonts || {}),
482
- }
483
-
484
- // Normalize and process appearance
485
- const appearance = normalizeAppearance(rawConfig.appearance)
486
-
487
- // Merge foundation variables with site overrides
488
- const mergedFoundationVars = mergeFoundationVars(
489
- foundationVars,
490
- rawConfig.vars || rawConfig.foundationVars || {}
491
- )
492
-
493
- // Validate merged foundation vars
494
- const foundationValidation = validateFoundationVars(mergedFoundationVars)
495
- if (!foundationValidation.valid) {
496
- warnings.push(...foundationValidation.errors)
497
- }
498
-
499
- // Process code block theme
500
- // These values are stored for runtime injection by kit's Code component
501
- // (not converted to CSS here - enables tree-shaking when code blocks aren't used)
502
- const code = {
503
- ...DEFAULT_CODE_THEME,
504
- ...(rawConfig.code || {}),
505
- }
506
-
507
- // Site background (pass through as CSS value)
508
- const background = rawConfig.background || null
509
-
510
- // Inline text styles (semantic names → CSS declarations)
511
- // Merge framework defaults with user overrides (user values win)
512
- const inline = { ...DEFAULT_INLINE, ...(rawConfig.inline || {}) }
513
-
514
- const config = {
515
- colors, // Raw colors for CSS generator
516
- palettes, // Generated palettes for Theme class
517
- contexts,
518
- fonts,
519
- appearance,
520
- foundationVars: mergedFoundationVars,
521
- code, // Code block theme for runtime injection
522
- background, // Site-level background CSS value
523
- inline, // Inline text style definitions
524
- }
525
-
526
- return { config, errors, warnings }
527
- }
528
-
529
- /**
530
- * Load foundation variables from vars.js export
531
- *
532
- * @param {Object} varsModule - Imported vars.js module
533
- * @returns {Object} Foundation variables
534
- */
535
- export function extractFoundationVars(varsModule) {
536
- if (!varsModule) {
537
- return {}
538
- }
539
-
540
- // Handle default export
541
- const module = varsModule.default || varsModule
542
-
543
- // Extract vars property or use whole object
544
- return module.vars || module
545
- }
546
-
547
- /**
548
- * Check if a foundation has theme variables
549
- *
550
- * @param {Object} foundationSchema - Foundation schema.json content
551
- * @returns {boolean}
552
- */
553
- export function foundationHasVars(foundationSchema) {
554
- // Check _self.vars (new), _self.themeVars (legacy), root themeVars (backwards compat)
555
- return (
556
- foundationSchema?._self?.vars != null ||
557
- foundationSchema?._self?.themeVars != null ||
558
- foundationSchema?.themeVars != null
559
- )
560
- }
561
-
562
- /**
563
- * Get foundation variables from schema
564
- * Supports both new 'vars' and legacy 'themeVars' naming
565
- *
566
- * @param {Object} foundationSchema - Foundation schema.json content
567
- * @returns {Object} Foundation variables
568
- */
569
- export function getFoundationVars(foundationSchema) {
570
- return (
571
- foundationSchema?._self?.vars ||
572
- foundationSchema?._self?.themeVars ||
573
- foundationSchema?.themeVars ||
574
- {}
575
- )
576
- }
577
-
578
- export default {
579
- validateThemeConfig,
580
- processTheme,
581
- extractFoundationVars,
582
- foundationHasVars,
583
- getFoundationVars,
584
- }