@uniweb/theming 0.1.1 → 0.1.3
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/package.json +5 -5
- package/src/css-generator.js +233 -63
- package/src/index.js +10 -2
- package/src/normalize.js +98 -0
- package/src/processor.js +62 -53
- package/src/section-overrides.js +216 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/theming",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Theming utilities for the Uniweb platform — shade generation, CSS generation, theme processing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=20.19"
|
|
28
28
|
},
|
|
29
|
-
"scripts": {
|
|
30
|
-
"test": "vitest run"
|
|
31
|
-
},
|
|
32
29
|
"devDependencies": {
|
|
33
30
|
"vitest": "^3.2.1"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run"
|
|
34
34
|
}
|
|
35
|
-
}
|
|
35
|
+
}
|
package/src/css-generator.js
CHANGED
|
@@ -26,6 +26,7 @@ const DEFAULT_CONTEXT_TOKENS = {
|
|
|
26
26
|
'ring': 'var(--primary-500)',
|
|
27
27
|
'link': 'var(--primary-600)',
|
|
28
28
|
'link-hover': 'var(--primary-700)',
|
|
29
|
+
'accent': 'var(--accent-600)',
|
|
29
30
|
'primary': 'var(--primary-600)',
|
|
30
31
|
'primary-foreground': 'white',
|
|
31
32
|
'primary-hover': 'var(--primary-700)',
|
|
@@ -54,6 +55,7 @@ const DEFAULT_CONTEXT_TOKENS = {
|
|
|
54
55
|
'ring': 'var(--primary-500)',
|
|
55
56
|
'link': 'var(--primary-600)',
|
|
56
57
|
'link-hover': 'var(--primary-700)',
|
|
58
|
+
'accent': 'var(--accent-600)',
|
|
57
59
|
'primary': 'var(--primary-600)',
|
|
58
60
|
'primary-foreground': 'white',
|
|
59
61
|
'primary-hover': 'var(--primary-700)',
|
|
@@ -72,7 +74,7 @@ const DEFAULT_CONTEXT_TOKENS = {
|
|
|
72
74
|
'info-subtle': '#eff6ff',
|
|
73
75
|
},
|
|
74
76
|
dark: {
|
|
75
|
-
'section': 'var(--neutral-
|
|
77
|
+
'section': 'var(--neutral-950)',
|
|
76
78
|
'card': 'var(--neutral-800)',
|
|
77
79
|
'muted': 'var(--neutral-700)',
|
|
78
80
|
'body': 'var(--neutral-50)',
|
|
@@ -82,6 +84,7 @@ const DEFAULT_CONTEXT_TOKENS = {
|
|
|
82
84
|
'ring': 'var(--primary-500)',
|
|
83
85
|
'link': 'var(--primary-400)',
|
|
84
86
|
'link-hover': 'var(--primary-300)',
|
|
87
|
+
'accent': 'var(--accent-400)',
|
|
85
88
|
'primary': 'var(--primary-500)',
|
|
86
89
|
'primary-foreground': 'white',
|
|
87
90
|
'primary-hover': 'var(--primary-400)',
|
|
@@ -150,55 +153,36 @@ function generatePaletteVars(palettes) {
|
|
|
150
153
|
*
|
|
151
154
|
* @param {string} context - Context name (light, medium, dark)
|
|
152
155
|
* @param {Object} tokens - Token overrides
|
|
156
|
+
* @param {Object} colorVars - Context-aware foundation color vars: { light: {...}, dark: {...} }
|
|
153
157
|
* @returns {string} CSS for context class
|
|
154
158
|
*/
|
|
155
|
-
function generateContextCSS(context, tokens = {}) {
|
|
159
|
+
function generateContextCSS(context, tokens = {}, colorVars = {}) {
|
|
156
160
|
const defaultTokens = DEFAULT_CONTEXT_TOKENS[context] || DEFAULT_CONTEXT_TOKENS.light
|
|
157
161
|
const mergedTokens = { ...defaultTokens, ...tokens }
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
// Merge context-aware color vars into the context class
|
|
164
|
+
const contextColorVars = colorVars[context] || {}
|
|
165
|
+
const allVars = { ...mergedTokens, ...contextColorVars }
|
|
166
|
+
|
|
167
|
+
const vars = generateVarDeclarations(allVars)
|
|
160
168
|
|
|
161
|
-
return `.context-${context} {\n${vars}\n
|
|
169
|
+
return `.context-${context} {\n${vars}\n}`
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
/**
|
|
165
173
|
* Generate dark scheme CSS (for site-wide dark mode toggle)
|
|
166
174
|
*
|
|
167
175
|
* @param {Object} config - Appearance configuration
|
|
176
|
+
* @param {Object} darkOverrides - Dark context token overrides
|
|
177
|
+
* @param {Object} colorVars - Context-aware foundation color vars: { light: {...}, dark: {...} }
|
|
168
178
|
* @returns {string} CSS for dark scheme support
|
|
169
179
|
*/
|
|
170
|
-
function generateDarkSchemeCSS(config = {}) {
|
|
171
|
-
const
|
|
180
|
+
function generateDarkSchemeCSS(config = {}, darkOverrides = {}, colorVars = {}) {
|
|
181
|
+
const respectSystemPreference = config.default === 'system'
|
|
172
182
|
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
'card': 'var(--neutral-900)',
|
|
177
|
-
'muted': 'var(--neutral-800)',
|
|
178
|
-
'body': 'var(--neutral-50)',
|
|
179
|
-
'heading': 'white',
|
|
180
|
-
'subtle': 'var(--neutral-400)',
|
|
181
|
-
'border': 'var(--neutral-800)',
|
|
182
|
-
'ring': 'var(--primary-500)',
|
|
183
|
-
'link': 'var(--primary-400)',
|
|
184
|
-
'link-hover': 'var(--primary-300)',
|
|
185
|
-
'primary': 'var(--primary-500)',
|
|
186
|
-
'primary-foreground': 'white',
|
|
187
|
-
'primary-hover': 'var(--primary-400)',
|
|
188
|
-
'primary-border': 'transparent',
|
|
189
|
-
'secondary': 'var(--neutral-800)',
|
|
190
|
-
'secondary-foreground': 'var(--neutral-100)',
|
|
191
|
-
'secondary-hover': 'var(--neutral-700)',
|
|
192
|
-
'secondary-border': 'var(--neutral-600)',
|
|
193
|
-
'success': '#4ade80',
|
|
194
|
-
'success-subtle': '#052e16',
|
|
195
|
-
'warning': '#fbbf24',
|
|
196
|
-
'warning-subtle': '#451a03',
|
|
197
|
-
'error': '#f87171',
|
|
198
|
-
'error-subtle': '#450a0a',
|
|
199
|
-
'info': '#60a5fa',
|
|
200
|
-
'info-subtle': '#172554',
|
|
201
|
-
}
|
|
183
|
+
// Merge default dark tokens with user element overrides and dark color vars
|
|
184
|
+
const darkColorVars = colorVars.dark || {}
|
|
185
|
+
const darkTokens = { ...DEFAULT_CONTEXT_TOKENS.dark, ...darkOverrides, ...darkColorVars }
|
|
202
186
|
|
|
203
187
|
const vars = generateVarDeclarations(darkTokens)
|
|
204
188
|
|
|
@@ -218,28 +202,177 @@ function generateDarkSchemeCSS(config = {}) {
|
|
|
218
202
|
return css
|
|
219
203
|
}
|
|
220
204
|
|
|
205
|
+
// Generic CSS font families that should not trigger font loading
|
|
206
|
+
const GENERIC_FAMILIES = new Set([
|
|
207
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
|
208
|
+
'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded',
|
|
209
|
+
'math', 'emoji', 'fangsong',
|
|
210
|
+
'inherit', 'initial', 'revert', 'unset',
|
|
211
|
+
])
|
|
212
|
+
|
|
221
213
|
/**
|
|
222
|
-
*
|
|
214
|
+
* Extract font family names actually used by body/heading/mono slots.
|
|
215
|
+
*
|
|
216
|
+
* Only considers slots the user explicitly configured (tracked via _userSlots
|
|
217
|
+
* from the processor). Default system font stacks are ignored so they don't
|
|
218
|
+
* trigger unnecessary font loading.
|
|
223
219
|
*
|
|
224
220
|
* @param {Object} fonts - Font configuration
|
|
225
|
-
* @returns {string}
|
|
221
|
+
* @returns {Set<string>} Lowercase family names referenced by user-set slots
|
|
222
|
+
*/
|
|
223
|
+
function extractUsedFamilies(fonts) {
|
|
224
|
+
const used = new Set()
|
|
225
|
+
const slots = fonts._userSlots || ['body', 'heading', 'mono']
|
|
226
|
+
|
|
227
|
+
for (const slot of slots) {
|
|
228
|
+
const value = fonts[slot]
|
|
229
|
+
if (!value) continue
|
|
230
|
+
|
|
231
|
+
for (const segment of value.split(',')) {
|
|
232
|
+
const name = segment.trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
233
|
+
if (name && !GENERIC_FAMILIES.has(name)) {
|
|
234
|
+
used.add(name)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return used
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Filter a Google Fonts URL to only include families in the used set.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} url - Google Fonts CSS URL
|
|
246
|
+
* @param {Set<string>} usedFamilies - Lowercase family names to keep
|
|
247
|
+
* @returns {string|null} Filtered URL, or null if no families remain
|
|
248
|
+
*/
|
|
249
|
+
function filterGoogleFontsUrl(url, usedFamilies) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = new URL(url)
|
|
252
|
+
const familyParams = parsed.searchParams.getAll('family')
|
|
253
|
+
if (familyParams.length === 0) return url
|
|
254
|
+
|
|
255
|
+
const kept = familyParams.filter((param) => {
|
|
256
|
+
// "Montserrat:wght@400;700" → "montserrat"
|
|
257
|
+
const name = param.split(':')[0].trim().toLowerCase()
|
|
258
|
+
return usedFamilies.has(name)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
if (kept.length === 0) return null
|
|
262
|
+
|
|
263
|
+
// Rebuild URL with only kept families
|
|
264
|
+
parsed.searchParams.delete('family')
|
|
265
|
+
for (const f of kept) {
|
|
266
|
+
parsed.searchParams.append('family', f)
|
|
267
|
+
}
|
|
268
|
+
return parsed.toString()
|
|
269
|
+
} catch {
|
|
270
|
+
return url // malformed URL — keep as-is
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check whether a URL points to Google Fonts.
|
|
276
|
+
*/
|
|
277
|
+
function isGoogleFontsUrl(url) {
|
|
278
|
+
try {
|
|
279
|
+
return new URL(url).hostname === 'fonts.googleapis.com'
|
|
280
|
+
} catch {
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Generate font CSS and external link tags.
|
|
287
|
+
*
|
|
288
|
+
* @param {Object} fonts - Font configuration
|
|
289
|
+
* @returns {{ css: string, links: string }} css for <style>, links for <head>
|
|
226
290
|
*/
|
|
227
291
|
function generateFontCSS(fonts = {}) {
|
|
228
|
-
const
|
|
292
|
+
const cssLines = []
|
|
293
|
+
const linkTags = []
|
|
294
|
+
|
|
295
|
+
const usedFamilies = extractUsedFamilies(fonts)
|
|
296
|
+
|
|
297
|
+
// --- @font-face rules from faces[] (filtered to used families) ---
|
|
298
|
+
// Also generate <link rel="preload"> hints so browsers fetch fonts early
|
|
299
|
+
const PRELOAD_TYPE = { woff2: 'font/woff2', woff: 'font/woff', truetype: 'font/ttf', opentype: 'font/otf' }
|
|
300
|
+
|
|
301
|
+
if (fonts.faces && Array.isArray(fonts.faces)) {
|
|
302
|
+
for (const face of fonts.faces) {
|
|
303
|
+
if (!face.family || !face.src) continue
|
|
304
|
+
if (!usedFamilies.has(face.family.toLowerCase())) continue
|
|
305
|
+
|
|
306
|
+
const format = face.format || (face.src.endsWith('.woff2') ? 'woff2' : 'woff')
|
|
307
|
+
cssLines.push(
|
|
308
|
+
`@font-face {`,
|
|
309
|
+
` font-family: ${face.family};`,
|
|
310
|
+
` src: url('${face.src}') format('${format}');`,
|
|
311
|
+
` font-weight: ${face.weight || 400};`,
|
|
312
|
+
` font-style: ${face.style || 'normal'};`,
|
|
313
|
+
` font-display: swap;`,
|
|
314
|
+
`}`,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
const mimeType = PRELOAD_TYPE[format]
|
|
318
|
+
if (mimeType) {
|
|
319
|
+
linkTags.push(`<link rel="preload" href="${face.src}" as="font" type="${mimeType}" crossorigin>`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (cssLines.length > 0) {
|
|
323
|
+
cssLines.push('') // blank line after @font-face block
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- External imports → <link> tags (filtered to used families) ---
|
|
328
|
+
const googleUrls = []
|
|
229
329
|
|
|
230
|
-
// Font imports
|
|
231
330
|
if (fonts.import && Array.isArray(fonts.import)) {
|
|
232
|
-
for (const
|
|
233
|
-
if (
|
|
234
|
-
|
|
331
|
+
for (const entry of fonts.import) {
|
|
332
|
+
if (!entry.url) continue
|
|
333
|
+
|
|
334
|
+
if (isGoogleFontsUrl(entry.url)) {
|
|
335
|
+
const filtered = filterGoogleFontsUrl(entry.url, usedFamilies)
|
|
336
|
+
if (filtered) googleUrls.push(filtered)
|
|
337
|
+
} else {
|
|
338
|
+
linkTags.push(`<link rel="stylesheet" href="${entry.url}">`)
|
|
235
339
|
}
|
|
236
340
|
}
|
|
237
|
-
|
|
238
|
-
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Merge Google Fonts into a single <link> with preconnect
|
|
344
|
+
if (googleUrls.length > 0) {
|
|
345
|
+
// Collect all family params from all URLs into one
|
|
346
|
+
const allFamilies = []
|
|
347
|
+
let display = 'swap'
|
|
348
|
+
for (const url of googleUrls) {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = new URL(url)
|
|
351
|
+
allFamilies.push(...parsed.searchParams.getAll('family'))
|
|
352
|
+
if (parsed.searchParams.get('display')) {
|
|
353
|
+
display = parsed.searchParams.get('display')
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
// skip malformed
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (allFamilies.length > 0) {
|
|
361
|
+
const merged = new URL('https://fonts.googleapis.com/css2')
|
|
362
|
+
for (const f of allFamilies) {
|
|
363
|
+
merged.searchParams.append('family', f)
|
|
364
|
+
}
|
|
365
|
+
merged.searchParams.set('display', display)
|
|
366
|
+
|
|
367
|
+
linkTags.unshift(
|
|
368
|
+
`<link rel="preconnect" href="https://fonts.googleapis.com">`,
|
|
369
|
+
`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`,
|
|
370
|
+
`<link rel="stylesheet" href="${merged.toString()}">`,
|
|
371
|
+
)
|
|
239
372
|
}
|
|
240
373
|
}
|
|
241
374
|
|
|
242
|
-
//
|
|
375
|
+
// --- :root font variables ---
|
|
243
376
|
const fontVars = []
|
|
244
377
|
if (fonts.body) {
|
|
245
378
|
fontVars.push(` --font-body: ${fonts.body};`)
|
|
@@ -252,14 +385,33 @@ function generateFontCSS(fonts = {}) {
|
|
|
252
385
|
}
|
|
253
386
|
|
|
254
387
|
if (fontVars.length > 0) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
388
|
+
cssLines.push(':root {')
|
|
389
|
+
cssLines.push(...fontVars)
|
|
390
|
+
cssLines.push('}')
|
|
258
391
|
}
|
|
259
392
|
|
|
260
|
-
return
|
|
393
|
+
return {
|
|
394
|
+
css: cssLines.join('\n'),
|
|
395
|
+
links: linkTags.join('\n'),
|
|
396
|
+
}
|
|
261
397
|
}
|
|
262
398
|
|
|
399
|
+
// Theme token names reserved by the theming engine — foundation vars must not use these
|
|
400
|
+
const RESERVED_VAR_NAMES = new Set([
|
|
401
|
+
// Semantic color tokens
|
|
402
|
+
'section', 'card', 'muted', 'body', 'heading', 'subtle',
|
|
403
|
+
'border', 'ring', 'link', 'link-hover', 'accent',
|
|
404
|
+
// Action tokens
|
|
405
|
+
'primary', 'primary-foreground', 'primary-hover', 'primary-border',
|
|
406
|
+
'secondary', 'secondary-foreground', 'secondary-hover', 'secondary-border',
|
|
407
|
+
// Status tokens
|
|
408
|
+
'success', 'success-subtle', 'warning', 'warning-subtle',
|
|
409
|
+
'error', 'error-subtle', 'info', 'info-subtle',
|
|
410
|
+
])
|
|
411
|
+
|
|
412
|
+
// Palette shade pattern: primary-50, neutral-950, accent-300, etc.
|
|
413
|
+
const PALETTE_SHADE_RE = /^(primary|secondary|accent|neutral)-\d+$/
|
|
414
|
+
|
|
263
415
|
/**
|
|
264
416
|
* Generate foundation-specific CSS variables
|
|
265
417
|
*
|
|
@@ -274,6 +426,12 @@ function generateFoundationVars(vars = {}) {
|
|
|
274
426
|
const declarations = []
|
|
275
427
|
|
|
276
428
|
for (const [name, config] of Object.entries(vars)) {
|
|
429
|
+
if (RESERVED_VAR_NAMES.has(name) || PALETTE_SHADE_RE.test(name)) {
|
|
430
|
+
console.warn(
|
|
431
|
+
`Warning: Foundation var '${name}' collides with a theme token. ` +
|
|
432
|
+
`Rename it to avoid unexpected behavior (e.g., '${name}-value' or '${name}-size').`
|
|
433
|
+
)
|
|
434
|
+
}
|
|
277
435
|
const value = typeof config === 'object' ? config.default : config
|
|
278
436
|
if (value !== undefined) {
|
|
279
437
|
declarations.push(` --${name}: ${value};`)
|
|
@@ -296,7 +454,7 @@ function generateFoundationVars(vars = {}) {
|
|
|
296
454
|
* @param {Object} config.fonts - Font configuration
|
|
297
455
|
* @param {Object} config.appearance - Appearance settings (dark mode, etc.)
|
|
298
456
|
* @param {Object} config.foundationVars - Foundation-specific variables
|
|
299
|
-
* @returns {string}
|
|
457
|
+
* @returns {{ css: string, links: string }} CSS string and font link tags
|
|
300
458
|
*/
|
|
301
459
|
export function generateThemeCSS(config = {}) {
|
|
302
460
|
const {
|
|
@@ -305,14 +463,15 @@ export function generateThemeCSS(config = {}) {
|
|
|
305
463
|
fonts = {},
|
|
306
464
|
appearance = {},
|
|
307
465
|
foundationVars = {},
|
|
466
|
+
colorVars = {},
|
|
308
467
|
} = config
|
|
309
468
|
|
|
310
469
|
const sections = []
|
|
311
470
|
|
|
312
|
-
// 1. Font
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
sections.push('/* Typography */\n' +
|
|
471
|
+
// 1. Font face rules and variables (links returned separately)
|
|
472
|
+
const fontResult = generateFontCSS(fonts)
|
|
473
|
+
if (fontResult.css) {
|
|
474
|
+
sections.push('/* Typography */\n' + fontResult.css)
|
|
316
475
|
}
|
|
317
476
|
|
|
318
477
|
// 2. Color palettes
|
|
@@ -321,15 +480,17 @@ export function generateThemeCSS(config = {}) {
|
|
|
321
480
|
sections.push(`/* Color Palettes */\n:root {\n${paletteVars}\n}`)
|
|
322
481
|
|
|
323
482
|
// 3. Default semantic tokens (applied to :root for global defaults)
|
|
324
|
-
|
|
483
|
+
// Include light color vars in the :root defaults
|
|
484
|
+
const lightColorVars = colorVars.light || {}
|
|
485
|
+
const defaultTokens = { ...DEFAULT_CONTEXT_TOKENS.light, ...(contexts.light || {}), ...lightColorVars }
|
|
325
486
|
const defaultVars = generateVarDeclarations(defaultTokens)
|
|
326
|
-
sections.push(`/* Default Semantic Tokens */\n:root {\n${defaultVars}\n}`)
|
|
487
|
+
sections.push(`/* Default Semantic Tokens */\n:root {\n${defaultVars}\n}\n\n/* Section backgrounds — applied once, resolves via context tokens */\n[id^="section-"] {\n background-color: var(--section);\n}`)
|
|
327
488
|
|
|
328
489
|
// 4. Context classes
|
|
329
490
|
const contextCSS = [
|
|
330
|
-
generateContextCSS('light', contexts.light),
|
|
331
|
-
generateContextCSS('medium', contexts.medium),
|
|
332
|
-
generateContextCSS('dark', contexts.dark),
|
|
491
|
+
generateContextCSS('light', contexts.light, colorVars),
|
|
492
|
+
generateContextCSS('medium', contexts.medium, colorVars),
|
|
493
|
+
generateContextCSS('dark', contexts.dark, colorVars),
|
|
333
494
|
]
|
|
334
495
|
sections.push('/* Color Contexts */\n' + contextCSS.join('\n\n'))
|
|
335
496
|
|
|
@@ -339,9 +500,9 @@ export function generateThemeCSS(config = {}) {
|
|
|
339
500
|
sections.push('/* Foundation Variables */\n' + foundationCSS)
|
|
340
501
|
}
|
|
341
502
|
|
|
342
|
-
// 6. Dark scheme support (if enabled)
|
|
343
|
-
if (appearance.allowToggle || appearance.schemes?.includes('dark')) {
|
|
344
|
-
sections.push(generateDarkSchemeCSS(appearance))
|
|
503
|
+
// 6. Dark scheme support (if enabled, default is dark, or follows system preference)
|
|
504
|
+
if (appearance.allowToggle || appearance.default === 'dark' || appearance.default === 'system' || appearance.schemes?.includes('dark')) {
|
|
505
|
+
sections.push(generateDarkSchemeCSS(appearance, contexts.dark, colorVars))
|
|
345
506
|
}
|
|
346
507
|
|
|
347
508
|
// 7. Site background (if specified in theme.yml)
|
|
@@ -364,7 +525,10 @@ export function generateThemeCSS(config = {}) {
|
|
|
364
525
|
}
|
|
365
526
|
}
|
|
366
527
|
|
|
367
|
-
return
|
|
528
|
+
return {
|
|
529
|
+
css: sections.join('\n\n'),
|
|
530
|
+
links: fontResult.links,
|
|
531
|
+
}
|
|
368
532
|
}
|
|
369
533
|
|
|
370
534
|
/**
|
|
@@ -391,10 +555,16 @@ export function getDefaultColors() {
|
|
|
391
555
|
return { ...DEFAULT_COLORS }
|
|
392
556
|
}
|
|
393
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Extract used font families (useful for testing)
|
|
560
|
+
*/
|
|
561
|
+
export { extractUsedFamilies }
|
|
562
|
+
|
|
394
563
|
export default {
|
|
395
564
|
generateThemeCSS,
|
|
396
565
|
generateContextCSS,
|
|
397
566
|
generatePaletteVars,
|
|
398
567
|
getDefaultContextTokens,
|
|
399
568
|
getDefaultColors,
|
|
569
|
+
extractUsedFamilies,
|
|
400
570
|
}
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export {
|
|
|
24
24
|
generatePaletteVars,
|
|
25
25
|
getDefaultContextTokens,
|
|
26
26
|
getDefaultColors,
|
|
27
|
+
extractUsedFamilies,
|
|
27
28
|
} from './css-generator.js'
|
|
28
29
|
|
|
29
30
|
// Theme processing
|
|
@@ -34,6 +35,12 @@ export {
|
|
|
34
35
|
foundationHasVars,
|
|
35
36
|
} from './processor.js'
|
|
36
37
|
|
|
38
|
+
// Value normalization
|
|
39
|
+
export { normalizeTokenValue } from './normalize.js'
|
|
40
|
+
|
|
41
|
+
// Section override CSS
|
|
42
|
+
export { buildSectionOverrides } from './section-overrides.js'
|
|
43
|
+
|
|
37
44
|
// Default export for convenience
|
|
38
45
|
import { processTheme } from './processor.js'
|
|
39
46
|
import { generateThemeCSS } from './css-generator.js'
|
|
@@ -44,14 +51,15 @@ import { generateThemeCSS } from './css-generator.js'
|
|
|
44
51
|
* @param {Object} themeYml - Raw theme.yml content
|
|
45
52
|
* @param {Object} options - Processing options
|
|
46
53
|
* @param {Object} options.foundationVars - Foundation variables
|
|
47
|
-
* @returns {{ css: string, config: Object, errors: string[], warnings: string[] }}
|
|
54
|
+
* @returns {{ css: string, links: string, config: Object, errors: string[], warnings: string[] }}
|
|
48
55
|
*/
|
|
49
56
|
export function buildTheme(themeYml = {}, options = {}) {
|
|
50
57
|
const { config, errors, warnings } = processTheme(themeYml, options)
|
|
51
|
-
const css = generateThemeCSS(config)
|
|
58
|
+
const { css, links } = generateThemeCSS(config)
|
|
52
59
|
|
|
53
60
|
return {
|
|
54
61
|
css,
|
|
62
|
+
links,
|
|
55
63
|
config,
|
|
56
64
|
errors,
|
|
57
65
|
warnings,
|
package/src/normalize.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Value Normalizer
|
|
3
|
+
*
|
|
4
|
+
* Converts stored token values into valid CSS. This is the single source
|
|
5
|
+
* of truth for normalizing values from all origins:
|
|
6
|
+
*
|
|
7
|
+
* - theme.yml contexts: bare palette refs like `neutral-500`
|
|
8
|
+
* - Section overrides (DB): `rgba(var(--primary-900) / 1.00)`
|
|
9
|
+
* - Frontmatter overrides: bare palette refs like `neutral-900`
|
|
10
|
+
*
|
|
11
|
+
* Also supports the opacity shorthand: `neutral-500/20` → 20% opacity.
|
|
12
|
+
*
|
|
13
|
+
* @module @uniweb/theming/normalize
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SHADE_LEVELS = new Set([50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950])
|
|
17
|
+
|
|
18
|
+
// Matches bare palette reference: "primary-500", "neutral-900", "--accent-200"
|
|
19
|
+
const PALETTE_REF_RE = /^([a-z][a-z0-9]*)-(\d+)$/
|
|
20
|
+
|
|
21
|
+
// Matches bare palette reference with opacity: "neutral-500/20", "primary-600/50"
|
|
22
|
+
const PALETTE_REF_OPACITY_RE = /^([a-z][a-z0-9]*-\d+)\/(\d+)$/
|
|
23
|
+
|
|
24
|
+
// Matches rgba(var(--X) / Y) — the DB storage format for token values with opacity
|
|
25
|
+
const RGBA_VAR_RE = /^rgba\(\s*(var\(--[^)]+\))\s*\/\s*([\d.]+)\s*\)$/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a token value to valid CSS.
|
|
29
|
+
*
|
|
30
|
+
* Handles all stored formats and produces CSS that works with any color format
|
|
31
|
+
* (OKLCH, RGB, hex) by using var() references and color-mix() for opacity.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} value - The stored token value
|
|
34
|
+
* @returns {string} Valid CSS value
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // Bare palette reference
|
|
38
|
+
* normalizeTokenValue('neutral-500') // → 'var(--neutral-500)'
|
|
39
|
+
*
|
|
40
|
+
* // Palette reference with opacity
|
|
41
|
+
* normalizeTokenValue('neutral-500/20') // → 'color-mix(in srgb, var(--neutral-500) 20%, transparent)'
|
|
42
|
+
*
|
|
43
|
+
* // DB format — full opacity (strip wrapper)
|
|
44
|
+
* normalizeTokenValue('rgba(var(--primary-900) / 1.00)') // → 'var(--primary-900)'
|
|
45
|
+
*
|
|
46
|
+
* // DB format — partial opacity (convert to color-mix)
|
|
47
|
+
* normalizeTokenValue('rgba(var(--primary-900) / 0.50)') // → 'color-mix(in srgb, var(--primary-900) 50%, transparent)'
|
|
48
|
+
*
|
|
49
|
+
* // Already valid CSS — pass through
|
|
50
|
+
* normalizeTokenValue('var(--neutral-500)') // → 'var(--neutral-500)'
|
|
51
|
+
* normalizeTokenValue('white') // → 'white'
|
|
52
|
+
* normalizeTokenValue('#ff0000') // → '#ff0000'
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeTokenValue(value) {
|
|
55
|
+
if (typeof value !== 'string') return value
|
|
56
|
+
|
|
57
|
+
// 1. Check for rgba(var(--X) / Y) — DB storage format
|
|
58
|
+
const rgbaMatch = value.match(RGBA_VAR_RE)
|
|
59
|
+
if (rgbaMatch) {
|
|
60
|
+
const varRef = rgbaMatch[1]
|
|
61
|
+
const opacity = parseFloat(rgbaMatch[2])
|
|
62
|
+
if (opacity >= 0.999) return varRef
|
|
63
|
+
return `color-mix(in srgb, ${varRef} ${Math.round(opacity * 100)}%, transparent)`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Already a CSS function (var(), color-mix(), rgb(), etc.) — pass through
|
|
67
|
+
if (value.includes('(')) return value
|
|
68
|
+
|
|
69
|
+
// 3. Hex color — pass through
|
|
70
|
+
if (value.startsWith('#')) return value
|
|
71
|
+
|
|
72
|
+
// 4. Strip leading dashes (--primary-500 → primary-500)
|
|
73
|
+
const bare = value.replace(/^-{0,2}/, '')
|
|
74
|
+
|
|
75
|
+
// 5. Check for palette reference with opacity: "neutral-500/20"
|
|
76
|
+
const opacityMatch = bare.match(PALETTE_REF_OPACITY_RE)
|
|
77
|
+
if (opacityMatch) {
|
|
78
|
+
const ref = opacityMatch[1]
|
|
79
|
+
const pct = parseInt(opacityMatch[2], 10)
|
|
80
|
+
const refMatch = ref.match(PALETTE_REF_RE)
|
|
81
|
+
if (refMatch && SHADE_LEVELS.has(parseInt(refMatch[2], 10))) {
|
|
82
|
+
if (pct >= 100) return `var(--${ref})`
|
|
83
|
+
return `color-mix(in srgb, var(--${ref}) ${pct}%, transparent)`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 6. Check for bare palette reference: "neutral-500", "primary-600"
|
|
88
|
+
const paletteMatch = bare.match(PALETTE_REF_RE)
|
|
89
|
+
if (paletteMatch) {
|
|
90
|
+
const shade = parseInt(paletteMatch[2], 10)
|
|
91
|
+
if (SHADE_LEVELS.has(shade)) {
|
|
92
|
+
return `var(--${bare})`
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 7. Anything else — pass through (named colors like 'white', 'transparent', etc.)
|
|
97
|
+
return value
|
|
98
|
+
}
|
package/src/processor.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { isValidColor, generatePalettes } from './shade-generator.js'
|
|
12
12
|
import { getDefaultColors, getDefaultContextTokens } from './css-generator.js'
|
|
13
|
+
import { normalizeTokenValue } from './normalize.js'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Named neutral presets mapping to Tailwind gray families
|
|
@@ -31,6 +32,10 @@ const DEFAULT_INLINE = {
|
|
|
31
32
|
color: 'var(--link)',
|
|
32
33
|
'font-weight': '600',
|
|
33
34
|
},
|
|
35
|
+
callout: {
|
|
36
|
+
color: 'var(--accent)',
|
|
37
|
+
'font-weight': '600',
|
|
38
|
+
},
|
|
34
39
|
muted: {
|
|
35
40
|
color: 'var(--subtle)',
|
|
36
41
|
},
|
|
@@ -85,45 +90,6 @@ const DEFAULT_CODE_THEME = {
|
|
|
85
90
|
selection: '#45475a', // Selection background
|
|
86
91
|
}
|
|
87
92
|
|
|
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
93
|
/**
|
|
128
94
|
* Validate color configuration
|
|
129
95
|
*
|
|
@@ -287,9 +253,16 @@ function validateCodeTheme(code) {
|
|
|
287
253
|
return { valid: errors.length === 0, errors }
|
|
288
254
|
}
|
|
289
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Context-aware var types (stored per light/dark context)
|
|
258
|
+
*/
|
|
259
|
+
const CONTEXT_AWARE_TYPES = new Set(['color', 'gradient'])
|
|
260
|
+
|
|
290
261
|
/**
|
|
291
262
|
* Validate foundation variables configuration
|
|
292
263
|
*
|
|
264
|
+
* Accepts enriched var schema: { default, description, label, type, options, group, globalOnly }
|
|
265
|
+
*
|
|
293
266
|
* @param {Object} vars - Foundation variables
|
|
294
267
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
295
268
|
*/
|
|
@@ -375,30 +348,60 @@ function normalizeAppearance(appearance) {
|
|
|
375
348
|
/**
|
|
376
349
|
* Merge foundation variables with site overrides
|
|
377
350
|
*
|
|
378
|
-
*
|
|
351
|
+
* Splits variables into two groups based on their type:
|
|
352
|
+
* - flat: context-independent vars (text, select, untyped) → foundationVars
|
|
353
|
+
* - colorVars: context-aware vars (color, gradient) → stored per light/dark context
|
|
354
|
+
*
|
|
355
|
+
* @param {Object} foundationVars - Variables from foundation vars.js (with enriched schema)
|
|
379
356
|
* @param {Object} siteVars - Site-level variable overrides
|
|
380
|
-
* @returns {Object}
|
|
357
|
+
* @returns {{ flat: Object, colorVars: Object }} Split variables
|
|
381
358
|
*/
|
|
382
359
|
function mergeFoundationVars(foundationVars = {}, siteVars = {}) {
|
|
383
|
-
const
|
|
360
|
+
const flat = {}
|
|
361
|
+
const colorVars = {}
|
|
384
362
|
|
|
385
|
-
// Start with foundation defaults
|
|
363
|
+
// Start with foundation defaults, split by type
|
|
386
364
|
for (const [name, config] of Object.entries(foundationVars)) {
|
|
387
|
-
|
|
365
|
+
const cfg = typeof config === 'object' ? { ...config } : { default: config }
|
|
366
|
+
const isContextAware = CONTEXT_AWARE_TYPES.has(cfg.type)
|
|
367
|
+
|
|
368
|
+
if (isContextAware) {
|
|
369
|
+
// Default value applies to all contexts
|
|
370
|
+
const defaultVal = cfg.default || ''
|
|
371
|
+
colorVars[name] = { light: defaultVal, dark: defaultVal }
|
|
372
|
+
} else {
|
|
373
|
+
flat[name] = cfg
|
|
374
|
+
}
|
|
388
375
|
}
|
|
389
376
|
|
|
390
377
|
// Apply site overrides
|
|
391
378
|
for (const [name, value] of Object.entries(siteVars)) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
379
|
+
// Look up type from foundation schema
|
|
380
|
+
const schemaCfg = foundationVars[name]
|
|
381
|
+
const type = typeof schemaCfg === 'object' ? schemaCfg?.type : undefined
|
|
382
|
+
const isContextAware = CONTEXT_AWARE_TYPES.has(type)
|
|
383
|
+
|
|
384
|
+
if (isContextAware) {
|
|
385
|
+
if (typeof value === 'object' && value !== null && (value.light || value.dark)) {
|
|
386
|
+
// Per-context override: { light: '#fff', dark: '#000' }
|
|
387
|
+
colorVars[name] = { ...(colorVars[name] || {}), ...value }
|
|
388
|
+
} else {
|
|
389
|
+
// Scalar override: apply same value to all contexts
|
|
390
|
+
const strVal = String(value)
|
|
391
|
+
colorVars[name] = { light: strVal, dark: strVal }
|
|
392
|
+
}
|
|
395
393
|
} else {
|
|
396
|
-
|
|
397
|
-
|
|
394
|
+
if (flat[name]) {
|
|
395
|
+
// Override the default value
|
|
396
|
+
flat[name].default = value
|
|
397
|
+
} else {
|
|
398
|
+
// New variable from site
|
|
399
|
+
flat[name] = { default: value }
|
|
400
|
+
}
|
|
398
401
|
}
|
|
399
402
|
}
|
|
400
403
|
|
|
401
|
-
return
|
|
404
|
+
return { flat, colorVars }
|
|
402
405
|
}
|
|
403
406
|
|
|
404
407
|
/**
|
|
@@ -469,7 +472,7 @@ export function processTheme(rawConfig = {}, options = {}) {
|
|
|
469
472
|
const normalized = {}
|
|
470
473
|
|
|
471
474
|
for (const [token, value] of Object.entries(overrides)) {
|
|
472
|
-
normalized[token] =
|
|
475
|
+
normalized[token] = normalizeTokenValue(value)
|
|
473
476
|
}
|
|
474
477
|
|
|
475
478
|
contexts[name] = { ...defaultContexts[name], ...normalized }
|
|
@@ -480,12 +483,17 @@ export function processTheme(rawConfig = {}, options = {}) {
|
|
|
480
483
|
...DEFAULT_FONTS,
|
|
481
484
|
...(rawConfig.fonts || {}),
|
|
482
485
|
}
|
|
486
|
+
// Track which font slots the user explicitly set (vs inherited defaults).
|
|
487
|
+
// Used by extractUsedFamilies to avoid loading fonts referenced only in defaults.
|
|
488
|
+
const userFonts = rawConfig.fonts || {}
|
|
489
|
+
fonts._userSlots = ['body', 'heading', 'mono'].filter((s) => userFonts[s] !== undefined)
|
|
483
490
|
|
|
484
491
|
// Normalize and process appearance
|
|
485
492
|
const appearance = normalizeAppearance(rawConfig.appearance)
|
|
486
493
|
|
|
487
494
|
// Merge foundation variables with site overrides
|
|
488
|
-
|
|
495
|
+
// Splits into context-independent (flat) and context-aware (colorVars)
|
|
496
|
+
const { flat: mergedFoundationVars, colorVars } = mergeFoundationVars(
|
|
489
497
|
foundationVars,
|
|
490
498
|
rawConfig.vars || rawConfig.foundationVars || {}
|
|
491
499
|
)
|
|
@@ -517,7 +525,8 @@ export function processTheme(rawConfig = {}, options = {}) {
|
|
|
517
525
|
contexts,
|
|
518
526
|
fonts,
|
|
519
527
|
appearance,
|
|
520
|
-
foundationVars: mergedFoundationVars,
|
|
528
|
+
foundationVars: mergedFoundationVars, // Context-independent vars only
|
|
529
|
+
colorVars, // Context-aware vars: { light: {...}, dark: {...} }
|
|
521
530
|
code, // Code block theme for runtime injection
|
|
522
531
|
background, // Site-level background CSS value
|
|
523
532
|
inline, // Inline text style definitions
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Override CSS Builder
|
|
3
|
+
*
|
|
4
|
+
* Builds a single CSS string from all section-level overrides on a page.
|
|
5
|
+
* Follows the same pattern as buildTheme() for global CSS:
|
|
6
|
+
* all overrides are pre-built into a <style id="uniweb-page-overrides"> tag.
|
|
7
|
+
*
|
|
8
|
+
* @module @uniweb/theming/section-overrides
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { generatePalettes } from './shade-generator.js'
|
|
12
|
+
import { normalizeTokenValue } from './normalize.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a block has any section-level overrides worth emitting.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} standardOptions - Block's standardOptions
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function hasOverrides(standardOptions) {
|
|
21
|
+
if (!standardOptions) return false
|
|
22
|
+
|
|
23
|
+
const { colors, foundationStyles } = standardOptions
|
|
24
|
+
|
|
25
|
+
if (foundationStyles && Object.keys(foundationStyles).length > 0) return true
|
|
26
|
+
if (!colors) return false
|
|
27
|
+
|
|
28
|
+
const hasColors =
|
|
29
|
+
colors.colors?.light && Object.keys(colors.colors.light).length > 0
|
|
30
|
+
const hasElements =
|
|
31
|
+
colors.elements &&
|
|
32
|
+
Object.values(colors.elements).some(
|
|
33
|
+
(ctx) => ctx && Object.keys(ctx).length > 0
|
|
34
|
+
)
|
|
35
|
+
return hasColors || hasElements
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build CSS variable declarations for base palette overrides.
|
|
40
|
+
* Base palette is context-independent — always stored under the 'light' key.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} baseColors - e.g. { primary: "#3b82f6", accent: "#f59e0b" }
|
|
43
|
+
* @returns {string[]} CSS declaration lines
|
|
44
|
+
*/
|
|
45
|
+
function buildPaletteVars(baseColors) {
|
|
46
|
+
if (!baseColors || Object.keys(baseColors).length === 0) return []
|
|
47
|
+
|
|
48
|
+
const vars = []
|
|
49
|
+
const palettes = generatePalettes(baseColors)
|
|
50
|
+
|
|
51
|
+
for (const [name, shades] of Object.entries(palettes)) {
|
|
52
|
+
for (const [level, value] of Object.entries(shades)) {
|
|
53
|
+
vars.push(` --${name}-${level}: ${value};`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return vars
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build CSS variable declarations for semantic token overrides.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} elements - e.g. { heading: "rgba(var(--primary-900) / 1.00)", ... }
|
|
64
|
+
* @returns {string[]} CSS declaration lines
|
|
65
|
+
*/
|
|
66
|
+
function buildElementVars(elements) {
|
|
67
|
+
if (!elements || Object.keys(elements).length === 0) return []
|
|
68
|
+
|
|
69
|
+
const vars = []
|
|
70
|
+
for (const [token, value] of Object.entries(elements)) {
|
|
71
|
+
if (value === '' || value == null) continue
|
|
72
|
+
vars.push(` --${token}: ${normalizeTokenValue(value)};`)
|
|
73
|
+
}
|
|
74
|
+
return vars
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build CSS variable declarations for foundation style overrides.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} styles - e.g. { "border-radius": "0.5rem" }
|
|
81
|
+
* @returns {string[]} CSS declaration lines
|
|
82
|
+
*/
|
|
83
|
+
function buildFoundationVars(styles) {
|
|
84
|
+
if (!styles || Object.keys(styles).length === 0) return []
|
|
85
|
+
|
|
86
|
+
const vars = []
|
|
87
|
+
for (const [name, value] of Object.entries(styles)) {
|
|
88
|
+
if (value === '' || value == null || typeof value === 'object') continue
|
|
89
|
+
vars.push(` --${name}: ${value};`)
|
|
90
|
+
}
|
|
91
|
+
return vars
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Split foundation styles into flat (context-independent) and
|
|
96
|
+
* context-keyed (color/gradient) values.
|
|
97
|
+
*
|
|
98
|
+
* Flat values: { "radius-xl": "1.5rem" }
|
|
99
|
+
* Context-keyed values: { "colorful-bg": { light: "linear-gradient(...)", dark: "..." } }
|
|
100
|
+
*
|
|
101
|
+
* @param {Object} styles - Foundation styles object
|
|
102
|
+
* @returns {{ flat: Object, contexts: Object }} - flat styles + { light: {...}, dark: {...} }
|
|
103
|
+
*/
|
|
104
|
+
function splitFoundationStyles(styles) {
|
|
105
|
+
if (!styles || Object.keys(styles).length === 0) return { flat: {}, contexts: {} }
|
|
106
|
+
|
|
107
|
+
const flat = {}
|
|
108
|
+
const contexts = {}
|
|
109
|
+
|
|
110
|
+
for (const [name, value] of Object.entries(styles)) {
|
|
111
|
+
if (value == null) continue
|
|
112
|
+
if (typeof value === 'object') {
|
|
113
|
+
// Context-keyed: { light: "...", dark: "..." }
|
|
114
|
+
for (const [ctx, ctxVal] of Object.entries(value)) {
|
|
115
|
+
if (ctxVal === '' || ctxVal == null) continue
|
|
116
|
+
if (!contexts[ctx]) contexts[ctx] = {}
|
|
117
|
+
contexts[ctx][name] = ctxVal
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
flat[name] = value
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { flat, contexts }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build a CSS rule for a section with its overrides.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} selector - CSS selector (e.g. "#section-42")
|
|
131
|
+
* @param {string[]} vars - CSS declaration lines
|
|
132
|
+
* @returns {string} CSS rule string (empty if no vars)
|
|
133
|
+
*/
|
|
134
|
+
function buildRule(selector, vars) {
|
|
135
|
+
if (vars.length === 0) return ''
|
|
136
|
+
return `${selector} {\n${vars.join('\n')}\n}\n`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build section override CSS for all blocks on a page.
|
|
141
|
+
*
|
|
142
|
+
* Takes an array of block-like objects and appearance config,
|
|
143
|
+
* returns a CSS string ready for injection into a <style> tag.
|
|
144
|
+
*
|
|
145
|
+
* Works with both Block instances and plain data objects — only reads:
|
|
146
|
+
* - block.stableId || block.id — for CSS selector
|
|
147
|
+
* - block.themeName — '' for Auto, 'light'/'medium'/'dark' for Pinned
|
|
148
|
+
* - block.standardOptions — { colors, foundationStyles }
|
|
149
|
+
* - block.componentVars — merged meta.js defaults + frontmatter overrides
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<Object>} blocks - Block data objects
|
|
152
|
+
* @param {Object} appearance - Theme appearance config
|
|
153
|
+
* @param {boolean} appearance.allowToggle - Whether scheme toggle is enabled
|
|
154
|
+
* @param {string} appearance.default - Default scheme ('light' or 'dark')
|
|
155
|
+
* @returns {string} CSS string (empty if no overrides)
|
|
156
|
+
*/
|
|
157
|
+
export function buildSectionOverrides(blocks, appearance = {}) {
|
|
158
|
+
if (!blocks || blocks.length === 0) return ''
|
|
159
|
+
|
|
160
|
+
let css = ''
|
|
161
|
+
|
|
162
|
+
for (const block of blocks) {
|
|
163
|
+
const hasStandardOverrides = hasOverrides(block.standardOptions)
|
|
164
|
+
const hasComponentVars = block.componentVars && Object.keys(block.componentVars).length > 0
|
|
165
|
+
|
|
166
|
+
if (!hasStandardOverrides && !hasComponentVars) continue
|
|
167
|
+
|
|
168
|
+
const { colors, foundationStyles } = block.standardOptions || {}
|
|
169
|
+
const sectionId = block.stableId || block.id
|
|
170
|
+
const selector = `#section-${sectionId}`
|
|
171
|
+
|
|
172
|
+
const isAuto = !block.themeName
|
|
173
|
+
const hasToggle = appearance.allowToggle
|
|
174
|
+
|
|
175
|
+
// Base palette is context-independent (always under 'light' key)
|
|
176
|
+
const paletteVars = buildPaletteVars(colors?.colors?.light)
|
|
177
|
+
|
|
178
|
+
// Split foundation styles: flat (context-independent) + context-keyed (color/gradient)
|
|
179
|
+
const { flat: flatFoundation, contexts: ctxFoundation } = splitFoundationStyles(foundationStyles)
|
|
180
|
+
const foundationVars = buildFoundationVars(flatFoundation)
|
|
181
|
+
|
|
182
|
+
// Component-level CSS vars are context-independent
|
|
183
|
+
const compVars = buildFoundationVars(block.componentVars)
|
|
184
|
+
|
|
185
|
+
if (isAuto && hasToggle) {
|
|
186
|
+
// Dual rules: light-scoped elements + dark-scoped elements
|
|
187
|
+
const lightElementVars = buildElementVars(colors?.elements?.light)
|
|
188
|
+
const darkElementVars = buildElementVars(colors?.elements?.dark)
|
|
189
|
+
|
|
190
|
+
// Context-aware foundation vars (color/gradient types)
|
|
191
|
+
const lightFoundationCtx = buildFoundationVars(ctxFoundation.light)
|
|
192
|
+
const darkFoundationCtx = buildFoundationVars(ctxFoundation.dark)
|
|
193
|
+
|
|
194
|
+
// Context-independent rule: palette + flat foundation + component vars (apply in both schemes)
|
|
195
|
+
const sharedVars = [...paletteVars, ...foundationVars, ...compVars]
|
|
196
|
+
css += buildRule(selector, sharedVars)
|
|
197
|
+
|
|
198
|
+
// Light-only overrides (elements + context-aware foundation vars)
|
|
199
|
+
css += buildRule(`:root:not(.scheme-dark) ${selector}`, [...lightElementVars, ...lightFoundationCtx])
|
|
200
|
+
|
|
201
|
+
// Dark-only overrides (elements + context-aware foundation vars)
|
|
202
|
+
css += buildRule(`.scheme-dark ${selector}`, [...darkElementVars, ...darkFoundationCtx])
|
|
203
|
+
} else {
|
|
204
|
+
// Single context: when toggle is off, always use 'light' bucket
|
|
205
|
+
// (overrides are context-independent); when toggle is on, use pinned context
|
|
206
|
+
const ctx = hasToggle ? (block.themeName || 'light') : 'light'
|
|
207
|
+
const elementVars = buildElementVars(colors?.elements?.[ctx])
|
|
208
|
+
const ctxFoundationVars = buildFoundationVars(ctxFoundation[ctx])
|
|
209
|
+
|
|
210
|
+
const allVars = [...paletteVars, ...elementVars, ...foundationVars, ...compVars, ...ctxFoundationVars]
|
|
211
|
+
css += buildRule(selector, allVars)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return css
|
|
216
|
+
}
|