@uniweb/build 0.1.32 → 0.2.0

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.
@@ -0,0 +1,422 @@
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
+ * Default appearance configuration
16
+ */
17
+ const DEFAULT_APPEARANCE = {
18
+ default: 'light', // Default color scheme
19
+ allowToggle: false, // Whether to show scheme toggle
20
+ respectSystemPreference: true, // Honor prefers-color-scheme
21
+ }
22
+
23
+ /**
24
+ * Default font configuration
25
+ */
26
+ const DEFAULT_FONTS = {
27
+ body: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
28
+ heading: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
29
+ mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace',
30
+ }
31
+
32
+ /**
33
+ * Validate color configuration
34
+ *
35
+ * @param {Object} colors - Color configuration object
36
+ * @returns {{ valid: boolean, errors: string[] }}
37
+ */
38
+ function validateColors(colors) {
39
+ const errors = []
40
+
41
+ if (!colors || typeof colors !== 'object') {
42
+ return { valid: true, errors } // No colors is valid (use defaults)
43
+ }
44
+
45
+ for (const [name, value] of Object.entries(colors)) {
46
+ // Skip pre-defined palette objects
47
+ if (typeof value === 'object' && value !== null) {
48
+ continue
49
+ }
50
+
51
+ if (typeof value !== 'string') {
52
+ errors.push(`Color "${name}" must be a string or shade object, got ${typeof value}`)
53
+ continue
54
+ }
55
+
56
+ if (!isValidColor(value)) {
57
+ errors.push(`Color "${name}" has invalid value: ${value}`)
58
+ }
59
+ }
60
+
61
+ return { valid: errors.length === 0, errors }
62
+ }
63
+
64
+ /**
65
+ * Validate context configuration
66
+ *
67
+ * @param {Object} contexts - Context configuration object
68
+ * @returns {{ valid: boolean, errors: string[] }}
69
+ */
70
+ function validateContexts(contexts) {
71
+ const errors = []
72
+
73
+ if (!contexts || typeof contexts !== 'object') {
74
+ return { valid: true, errors }
75
+ }
76
+
77
+ const validContexts = ['light', 'medium', 'dark']
78
+
79
+ for (const [context, tokens] of Object.entries(contexts)) {
80
+ if (!validContexts.includes(context)) {
81
+ errors.push(`Unknown context "${context}". Valid contexts: ${validContexts.join(', ')}`)
82
+ continue
83
+ }
84
+
85
+ if (typeof tokens !== 'object' || tokens === null) {
86
+ errors.push(`Context "${context}" must be an object`)
87
+ }
88
+ }
89
+
90
+ return { valid: errors.length === 0, errors }
91
+ }
92
+
93
+ /**
94
+ * Validate font configuration
95
+ *
96
+ * @param {Object} fonts - Font configuration object
97
+ * @returns {{ valid: boolean, errors: string[] }}
98
+ */
99
+ function validateFonts(fonts) {
100
+ const errors = []
101
+
102
+ if (!fonts || typeof fonts !== 'object') {
103
+ return { valid: true, errors }
104
+ }
105
+
106
+ // Validate imports
107
+ if (fonts.import !== undefined) {
108
+ if (!Array.isArray(fonts.import)) {
109
+ errors.push('fonts.import must be an array')
110
+ } else {
111
+ for (const [index, item] of fonts.import.entries()) {
112
+ if (typeof item !== 'object' || !item.url) {
113
+ errors.push(`fonts.import[${index}] must have a "url" property`)
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return { valid: errors.length === 0, errors }
120
+ }
121
+
122
+ /**
123
+ * Validate appearance configuration
124
+ *
125
+ * @param {Object} appearance - Appearance configuration
126
+ * @returns {{ valid: boolean, errors: string[] }}
127
+ */
128
+ function validateAppearance(appearance) {
129
+ const errors = []
130
+
131
+ if (!appearance || typeof appearance !== 'object') {
132
+ // Simple string value (e.g., appearance: light)
133
+ if (typeof appearance === 'string') {
134
+ if (!['light', 'dark', 'system'].includes(appearance)) {
135
+ errors.push(`Invalid appearance value: ${appearance}. Must be "light", "dark", or "system"`)
136
+ }
137
+ }
138
+ return { valid: errors.length === 0, errors }
139
+ }
140
+
141
+ if (appearance.default && !['light', 'dark', 'system'].includes(appearance.default)) {
142
+ errors.push(`Invalid appearance.default: ${appearance.default}`)
143
+ }
144
+
145
+ if (appearance.schemes !== undefined) {
146
+ if (!Array.isArray(appearance.schemes)) {
147
+ errors.push('appearance.schemes must be an array')
148
+ } else {
149
+ const validSchemes = ['light', 'dark']
150
+ for (const scheme of appearance.schemes) {
151
+ if (!validSchemes.includes(scheme)) {
152
+ errors.push(`Invalid scheme: ${scheme}. Valid schemes: ${validSchemes.join(', ')}`)
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ return { valid: errors.length === 0, errors }
159
+ }
160
+
161
+ /**
162
+ * Validate foundation variables configuration
163
+ *
164
+ * @param {Object} vars - Foundation variables
165
+ * @returns {{ valid: boolean, errors: string[] }}
166
+ */
167
+ function validateFoundationVars(vars) {
168
+ const errors = []
169
+
170
+ if (!vars || typeof vars !== 'object') {
171
+ return { valid: true, errors }
172
+ }
173
+
174
+ for (const [name, config] of Object.entries(vars)) {
175
+ // Variable name validation
176
+ if (!/^[a-z][a-z0-9-]*$/i.test(name)) {
177
+ errors.push(`Invalid variable name "${name}". Use lowercase letters, numbers, and hyphens.`)
178
+ }
179
+
180
+ // Config validation
181
+ if (typeof config !== 'object' && typeof config !== 'string' && typeof config !== 'number') {
182
+ errors.push(`Variable "${name}" must have a string, number, or config object value`)
183
+ }
184
+ }
185
+
186
+ return { valid: errors.length === 0, errors }
187
+ }
188
+
189
+ /**
190
+ * Validate complete theme configuration
191
+ *
192
+ * @param {Object} config - Raw theme configuration
193
+ * @returns {{ valid: boolean, errors: string[] }}
194
+ */
195
+ export function validateThemeConfig(config) {
196
+ const allErrors = []
197
+
198
+ if (!config || typeof config !== 'object') {
199
+ return { valid: true, errors: [] } // Empty config is valid (use all defaults)
200
+ }
201
+
202
+ const colorValidation = validateColors(config.colors)
203
+ const contextValidation = validateContexts(config.contexts)
204
+ const fontValidation = validateFonts(config.fonts)
205
+ const appearanceValidation = validateAppearance(config.appearance)
206
+
207
+ allErrors.push(...colorValidation.errors)
208
+ allErrors.push(...contextValidation.errors)
209
+ allErrors.push(...fontValidation.errors)
210
+ allErrors.push(...appearanceValidation.errors)
211
+
212
+ return {
213
+ valid: allErrors.length === 0,
214
+ errors: allErrors,
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Normalize appearance configuration
220
+ *
221
+ * @param {string|Object} appearance - Raw appearance config
222
+ * @returns {Object} Normalized appearance config
223
+ */
224
+ function normalizeAppearance(appearance) {
225
+ if (!appearance) {
226
+ return { ...DEFAULT_APPEARANCE }
227
+ }
228
+
229
+ // Simple string value: "light", "dark", or "system"
230
+ if (typeof appearance === 'string') {
231
+ return {
232
+ default: appearance,
233
+ allowToggle: false,
234
+ respectSystemPreference: appearance === 'system',
235
+ }
236
+ }
237
+
238
+ return {
239
+ ...DEFAULT_APPEARANCE,
240
+ ...appearance,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Merge foundation variables with site overrides
246
+ *
247
+ * @param {Object} foundationVars - Variables from foundation vars.js
248
+ * @param {Object} siteVars - Site-level variable overrides
249
+ * @returns {Object} Merged variables
250
+ */
251
+ function mergeFoundationVars(foundationVars = {}, siteVars = {}) {
252
+ const merged = {}
253
+
254
+ // Start with foundation defaults
255
+ for (const [name, config] of Object.entries(foundationVars)) {
256
+ merged[name] = typeof config === 'object' ? { ...config } : { default: config }
257
+ }
258
+
259
+ // Apply site overrides
260
+ for (const [name, value] of Object.entries(siteVars)) {
261
+ if (merged[name]) {
262
+ // Override the default value
263
+ merged[name].default = value
264
+ } else {
265
+ // New variable from site
266
+ merged[name] = { default: value }
267
+ }
268
+ }
269
+
270
+ return merged
271
+ }
272
+
273
+ /**
274
+ * Process raw theme configuration into a complete, validated config
275
+ *
276
+ * @param {Object} rawConfig - Raw theme.yml content
277
+ * @param {Object} options - Processing options
278
+ * @param {Object} options.foundationVars - Foundation variables from vars.js
279
+ * @param {boolean} options.strict - Throw on validation errors (default: false)
280
+ * @returns {{ config: Object, errors: string[], warnings: string[] }}
281
+ */
282
+ export function processTheme(rawConfig = {}, options = {}) {
283
+ const { foundationVars = {}, strict = false } = options
284
+ const errors = []
285
+ const warnings = []
286
+
287
+ // Validate raw config
288
+ const validation = validateThemeConfig(rawConfig)
289
+ if (!validation.valid) {
290
+ errors.push(...validation.errors)
291
+ if (strict) {
292
+ throw new Error(`Theme configuration errors:\n${errors.join('\n')}`)
293
+ }
294
+ }
295
+
296
+ // Process colors
297
+ const defaultColors = getDefaultColors()
298
+ const rawColors = rawConfig.colors || {}
299
+
300
+ // Filter to only valid colors (skip invalid ones in non-strict mode)
301
+ const validColors = {}
302
+ for (const [name, value] of Object.entries({ ...defaultColors, ...rawColors })) {
303
+ // Skip objects (pre-defined palettes) or invalid color strings
304
+ if (typeof value === 'object' && value !== null) {
305
+ validColors[name] = value
306
+ } else if (isValidColor(value)) {
307
+ validColors[name] = value
308
+ }
309
+ // Invalid colors are skipped (error already recorded during validation)
310
+ }
311
+
312
+ const colors = validColors
313
+
314
+ // Generate color palettes (shades 50-950 for each color)
315
+ // This is used by the Theme class for runtime color access
316
+ const palettes = generatePalettes(colors)
317
+
318
+ // Warn if required colors are missing
319
+ if (!rawConfig.colors?.primary) {
320
+ warnings.push('No primary color specified, using default blue (#3b82f6)')
321
+ }
322
+ if (!rawConfig.colors?.neutral) {
323
+ warnings.push('No neutral color specified, using default zinc (#71717a)')
324
+ }
325
+
326
+ // Process contexts
327
+ const defaultContexts = getDefaultContextTokens()
328
+ const contexts = {
329
+ light: { ...defaultContexts.light, ...(rawConfig.contexts?.light || {}) },
330
+ medium: { ...defaultContexts.medium, ...(rawConfig.contexts?.medium || {}) },
331
+ dark: { ...defaultContexts.dark, ...(rawConfig.contexts?.dark || {}) },
332
+ }
333
+
334
+ // Process fonts
335
+ const fonts = {
336
+ ...DEFAULT_FONTS,
337
+ ...(rawConfig.fonts || {}),
338
+ }
339
+
340
+ // Normalize and process appearance
341
+ const appearance = normalizeAppearance(rawConfig.appearance)
342
+
343
+ // Merge foundation variables with site overrides
344
+ const mergedFoundationVars = mergeFoundationVars(
345
+ foundationVars,
346
+ rawConfig.vars || rawConfig.foundationVars || {}
347
+ )
348
+
349
+ // Validate merged foundation vars
350
+ const foundationValidation = validateFoundationVars(mergedFoundationVars)
351
+ if (!foundationValidation.valid) {
352
+ warnings.push(...foundationValidation.errors)
353
+ }
354
+
355
+ const config = {
356
+ colors, // Raw colors for CSS generator
357
+ palettes, // Generated palettes for Theme class
358
+ contexts,
359
+ fonts,
360
+ appearance,
361
+ foundationVars: mergedFoundationVars,
362
+ }
363
+
364
+ return { config, errors, warnings }
365
+ }
366
+
367
+ /**
368
+ * Load foundation variables from vars.js export
369
+ *
370
+ * @param {Object} varsModule - Imported vars.js module
371
+ * @returns {Object} Foundation variables
372
+ */
373
+ export function extractFoundationVars(varsModule) {
374
+ if (!varsModule) {
375
+ return {}
376
+ }
377
+
378
+ // Handle default export
379
+ const module = varsModule.default || varsModule
380
+
381
+ // Extract vars property or use whole object
382
+ return module.vars || module
383
+ }
384
+
385
+ /**
386
+ * Check if a foundation has theme variables
387
+ *
388
+ * @param {Object} foundationSchema - Foundation schema.json content
389
+ * @returns {boolean}
390
+ */
391
+ export function foundationHasVars(foundationSchema) {
392
+ // Check _self.vars (new), _self.themeVars (legacy), root themeVars (backwards compat)
393
+ return (
394
+ foundationSchema?._self?.vars != null ||
395
+ foundationSchema?._self?.themeVars != null ||
396
+ foundationSchema?.themeVars != null
397
+ )
398
+ }
399
+
400
+ /**
401
+ * Get foundation variables from schema
402
+ * Supports both new 'vars' and legacy 'themeVars' naming
403
+ *
404
+ * @param {Object} foundationSchema - Foundation schema.json content
405
+ * @returns {Object} Foundation variables
406
+ */
407
+ export function getFoundationVars(foundationSchema) {
408
+ return (
409
+ foundationSchema?._self?.vars ||
410
+ foundationSchema?._self?.themeVars ||
411
+ foundationSchema?.themeVars ||
412
+ {}
413
+ )
414
+ }
415
+
416
+ export default {
417
+ validateThemeConfig,
418
+ processTheme,
419
+ extractFoundationVars,
420
+ foundationHasVars,
421
+ getFoundationVars,
422
+ }