@uniweb/theming 1.0.1 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/theming",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Theming utilities for the Uniweb platform — shade generation, CSS generation, theme processing",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/uniweb/theming/issues"
25
25
  },
26
26
  "engines": {
27
- "node": ">=20.19"
27
+ "node": ">=22.12"
28
28
  },
29
29
  "devDependencies": {
30
30
  "vitest": "^4.1.7"
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { generatePalettes, formatOklch } from './shade-generator.js'
14
+ import { closeToMarkup, escapeAttribute } from './values.js'
14
15
 
15
16
  // Default semantic tokens for each context
16
17
  // These map abstract concepts to specific palette values
@@ -377,7 +378,7 @@ function generateFontCSS(fontRoles = {}, fonts = {}, base = '/') {
377
378
 
378
379
  const mimeType = PRELOAD_TYPE[format]
379
380
  if (mimeType) {
380
- linkTags.push(`<link rel="preload" href="${src}" as="font" type="${mimeType}" crossorigin>`)
381
+ linkTags.push(`<link rel="preload" href="${escapeAttribute(src)}" as="font" type="${mimeType}" crossorigin>`)
381
382
  }
382
383
  }
383
384
  if (cssLines.length > 0) {
@@ -397,7 +398,7 @@ function generateFontCSS(fontRoles = {}, fonts = {}, base = '/') {
397
398
  const filtered = filterGoogleFontsUrl(entry.url, usedFamilies)
398
399
  if (filtered) googleUrls.push(filtered)
399
400
  } else {
400
- linkTags.push(`<link rel="stylesheet" href="${entry.url}">`)
401
+ linkTags.push(`<link rel="stylesheet" href="${escapeAttribute(entry.url)}">`)
401
402
  try {
402
403
  otherOrigins.add(new URL(entry.url).origin)
403
404
  } catch {
@@ -412,7 +413,7 @@ function generateFontCSS(fontRoles = {}, fonts = {}, base = '/') {
412
413
  // emitted below, alongside the merged css2 link. Emitting every font <link>
413
414
  // from one place keeps consumers from having to re-derive any of them.
414
415
  for (const origin of otherOrigins) {
415
- linkTags.unshift(`<link rel="preconnect" href="${origin}">`)
416
+ linkTags.unshift(`<link rel="preconnect" href="${escapeAttribute(origin)}">`)
416
417
  }
417
418
 
418
419
  // Merge Google Fonts into a single <link> with preconnect
@@ -448,7 +449,9 @@ function generateFontCSS(fontRoles = {}, fonts = {}, base = '/') {
448
449
  linkTags.unshift(
449
450
  `<link rel="preconnect" href="https://fonts.googleapis.com">`,
450
451
  `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`,
451
- `<link rel="stylesheet" href="${href}">`,
452
+ // The family names are the author's, decoded from their URL — escaped
453
+ // here, so a quote in one cannot open an attribute.
454
+ `<link rel="stylesheet" href="${escapeAttribute(href)}">`,
452
455
  )
453
456
  }
454
457
  }
@@ -648,7 +651,8 @@ export function generateThemeCSS(config = {}, options = {}) {
648
651
  }
649
652
 
650
653
  return {
651
- css: sections.join('\n\n'),
654
+ // Written into a <style> in a prerendered page's <head> — see values.js.
655
+ css: closeToMarkup(sections.join('\n\n')),
652
656
  links: fontResult.links,
653
657
  }
654
658
  }
package/src/index.js CHANGED
@@ -41,6 +41,10 @@ export {
41
41
  // Value normalization
42
42
  export { normalizeTokenValue } from './normalize.js'
43
43
 
44
+ // What a theme value and name may be — one rule for the stylesheet and anything
45
+ // else that writes a theme value into a page (values.js)
46
+ export { isCssValue, isCssName, isLoadableUrl } from './values.js'
47
+
44
48
  // Section override CSS
45
49
  export { buildSectionOverrides } from './section-overrides.js'
46
50
 
package/src/processor.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { isValidColor, generatePalettes } from './shade-generator.js'
12
12
  import { getDefaultColors, getDefaultContextTokens } from './css-generator.js'
13
13
  import { normalizeTokenValue } from './normalize.js'
14
+ import { isCssName, isCssProperty, isCssValue, isLoadableUrl, keepWritable } from './values.js'
14
15
 
15
16
  /**
16
17
  * Named neutral presets mapping to Tailwind gray families
@@ -476,6 +477,72 @@ function mergeFoundationVars(foundationVars = {}, siteVars = {}) {
476
477
  return { flat, colorVars }
477
478
  }
478
479
 
480
+ /**
481
+ * The variables a stylesheet can hold: a name, and a value — for a context-aware
482
+ * one, a value per context. Anything else is left out, with a warning.
483
+ *
484
+ * @param {{ flat: Object, colorVars: Object }} merged - mergeFoundationVars' result
485
+ * @param {string[]} warnings
486
+ * @returns {{ flat: Object, colorVars: Object }}
487
+ */
488
+ function keepWritableVars({ flat, colorVars }, warnings) {
489
+ const keptFlat = {}
490
+ for (const [name, cfg] of Object.entries(flat)) {
491
+ if (!isCssName(name)) {
492
+ warnings.push(`theme: vars: \`${name}\` is not a name a stylesheet can hold — left out`)
493
+ continue
494
+ }
495
+ const value = cfg?.default
496
+ if (value !== null && value !== undefined && value !== '' && !isCssValue(value)) {
497
+ warnings.push(`theme: vars.${name}: ${JSON.stringify(value)} is not a CSS value — left out`)
498
+ continue
499
+ }
500
+ keptFlat[name] = cfg
501
+ }
502
+ const keptColorVars = {}
503
+ for (const [name, byContext] of Object.entries(colorVars)) {
504
+ if (!isCssName(name)) {
505
+ warnings.push(`theme: vars: \`${name}\` is not a name a stylesheet can hold — left out`)
506
+ continue
507
+ }
508
+ keptColorVars[name] = keepWritable(byContext, `vars.${name}`, warnings, () => true)
509
+ }
510
+ return { flat: keptFlat, colorVars: keptColorVars }
511
+ }
512
+
513
+ /** A font import is a stylesheet a page loads, so its URL is one a page may load. */
514
+ function keepLoadableImports(imports, warnings) {
515
+ if (!Array.isArray(imports)) return imports
516
+ return imports.filter((entry) => {
517
+ if (!entry?.url || isLoadableUrl(entry.url)) return true
518
+ warnings.push(`theme: fonts.import: ${JSON.stringify(entry.url)} is not a URL a page may load — left out`)
519
+ return false
520
+ })
521
+ }
522
+
523
+ /**
524
+ * The format names CSS defines for a face's `format()` — a closed list, so a face's
525
+ * `format` can only ever be one of these words. ⛔ *Until 2026-10-15 it held four of
526
+ * the seven, and an `.eot` face (`embedded-opentype`) was left out.*
527
+ */
528
+ const FONT_FORMATS = new Set(['woff2', 'woff', 'truetype', 'opentype', 'embedded-opentype', 'svg', 'collection'])
529
+
530
+ /** A font face is written as an `@font-face` rule and a preload `<link>`. */
531
+ function keepWritableFaces(faces, warnings) {
532
+ if (!Array.isArray(faces)) return faces
533
+ return faces.filter((face) => {
534
+ const ok =
535
+ face && typeof face === 'object' &&
536
+ (face.family === undefined || isCssValue(face.family)) &&
537
+ (face.src === undefined || isLoadableUrl(face.src)) &&
538
+ (face.weight === undefined || isCssValue(face.weight)) &&
539
+ (face.style === undefined || isCssValue(face.style)) &&
540
+ (face.format === undefined || FONT_FORMATS.has(face.format))
541
+ if (!ok) warnings.push(`theme: fonts.faces: ${JSON.stringify(face)} is not a face a stylesheet can hold — left out`)
542
+ return ok
543
+ })
544
+ }
545
+
479
546
  /**
480
547
  * Process raw theme configuration into a complete, validated config
481
548
  *
@@ -524,9 +591,16 @@ export function processTheme(rawConfig = {}, options = {}) {
524
591
  // Filter to only valid colors (skip invalid ones in non-strict mode)
525
592
  const validColors = {}
526
593
  for (const [name, value] of Object.entries({ ...defaultColors, ...rawColors })) {
527
- // Skip objects (pre-defined palettes) or invalid color strings
594
+ // A colour's name becomes `--<name>-<shade>`, so it is a name or nothing.
595
+ if (!isCssName(name)) {
596
+ warnings.push(`theme: colors: \`${name}\` is not a name a stylesheet can hold — left out`)
597
+ continue
598
+ }
599
+ // A pre-defined palette is written shade by shade, so each shade is a value.
528
600
  if (typeof value === 'object' && value !== null) {
529
- validColors[name] = value
601
+ validColors[name] = value.base === undefined
602
+ ? keepWritable(value, `colors.${name}`, warnings, (shade) => /^\d+$/.test(shade))
603
+ : value
530
604
  } else if (isValidColor(value)) {
531
605
  validColors[name] = value
532
606
  }
@@ -560,7 +634,7 @@ export function processTheme(rawConfig = {}, options = {}) {
560
634
  normalized[token] = normalizeTokenValue(value)
561
635
  }
562
636
 
563
- contexts[name] = { ...defaultContexts[name], ...normalized }
637
+ contexts[name] = { ...defaultContexts[name], ...keepWritable(normalized, `contexts.${name}`, warnings) }
564
638
  }
565
639
 
566
640
  // Fonts resolve into the font-role namespace after foundation vars are merged
@@ -575,9 +649,9 @@ export function processTheme(rawConfig = {}, options = {}) {
575
649
 
576
650
  // Merge foundation variables with site overrides
577
651
  // Splits into context-independent (flat) and context-aware (colorVars)
578
- const { flat: mergedFoundationVars, colorVars } = mergeFoundationVars(
579
- foundationVars,
580
- rawConfig.vars || rawConfig.foundationVars || {}
652
+ const { flat: mergedFoundationVars, colorVars } = keepWritableVars(
653
+ mergeFoundationVars(foundationVars, rawConfig.vars || rawConfig.foundationVars || {}),
654
+ warnings
581
655
  )
582
656
 
583
657
  // Validate merged foundation vars
@@ -626,6 +700,10 @@ export function processTheme(rawConfig = {}, options = {}) {
626
700
  const FONT_LOADING_KEYS = new Set(['import', 'faces'])
627
701
  for (const [rawName, value] of Object.entries(rawFonts)) {
628
702
  if (FONT_LOADING_KEYS.has(rawName)) continue
703
+ if (!isCssName(rawName) || (value !== null && value !== undefined && !isCssValue(value))) {
704
+ warnings.push(`theme: fonts.${rawName}: ${JSON.stringify(value)} is not a font a stylesheet can hold — left out`)
705
+ continue
706
+ }
629
707
  const name = normalizeFontRoleName(rawName)
630
708
  const existing = fontRoles[name]
631
709
  fontRoles[name] = {
@@ -654,8 +732,8 @@ export function processTheme(rawConfig = {}, options = {}) {
654
732
 
655
733
  // config.fonts: name→value (for the Theme API) plus import/faces (loading).
656
734
  const fonts = {}
657
- if (rawFonts.import !== undefined) fonts.import = rawFonts.import
658
- if (rawFonts.faces !== undefined) fonts.faces = rawFonts.faces
735
+ if (rawFonts.import !== undefined) fonts.import = keepLoadableImports(rawFonts.import, warnings)
736
+ if (rawFonts.faces !== undefined) fonts.faces = keepWritableFaces(rawFonts.faces, warnings)
659
737
  for (const [name, role] of Object.entries(fontRoles)) {
660
738
  fonts[name] = role.value
661
739
  }
@@ -670,13 +748,29 @@ export function processTheme(rawConfig = {}, options = {}) {
670
748
  // fifteen, and the theme it named would be overridden in full by values it
671
749
  // never asked for. Whoever renders code owns the default; this only carries
672
750
  // what the site said.
673
- const code = rawConfig.code ?? null
751
+ const code = rawConfig.code && typeof rawConfig.code === 'object'
752
+ ? keepWritable(rawConfig.code, 'code', warnings)
753
+ : rawConfig.code ?? null
674
754
 
675
755
  // Site background (pass through as CSS value)
676
- const background = rawConfig.background || null
677
-
678
- // Inline text styles (semantic names → CSS declarations)
679
- const inline = mergeInlineStyles(DEFAULT_INLINE, rawConfig.inline)
756
+ let background = rawConfig.background || null
757
+ if (background !== null && !isCssValue(background)) {
758
+ warnings.push(`theme: background: ${JSON.stringify(background)} is not a CSS value — left out`)
759
+ background = null
760
+ }
761
+
762
+ // Inline text styles (semantic names → CSS declarations). A name is written as
763
+ // `span[<name>]`, each property as a declaration.
764
+ const inline = {}
765
+ for (const [name, styles] of Object.entries(mergeInlineStyles(DEFAULT_INLINE, rawConfig.inline))) {
766
+ if (!isCssName(name)) {
767
+ warnings.push(`theme: inline: \`${name}\` is not a style name — left out`)
768
+ continue
769
+ }
770
+ inline[name] = styles && typeof styles === 'object'
771
+ ? keepWritable(styles, `inline.${name}`, warnings, isCssProperty)
772
+ : styles
773
+ }
680
774
 
681
775
  const config = {
682
776
  colors, // Raw colors for CSS generator
@@ -8,8 +8,14 @@
8
8
  * @module @uniweb/theming/section-overrides
9
9
  */
10
10
 
11
- import { generatePalettes } from './shade-generator.js'
11
+ import { generatePalettes, isValidColor } from './shade-generator.js'
12
12
  import { normalizeTokenValue } from './normalize.js'
13
+ import { closeToMarkup, isCssName, isCssValue } from './values.js'
14
+
15
+ // A section's overrides are authored with the section, so every name and value
16
+ // is held to what a stylesheet can hold, as the site's theme is (values.js) —
17
+ // here without a warning: this runs as a page renders, where nothing reads one.
18
+ const writable = (name, value) => isCssName(name) && isCssValue(value)
13
19
 
14
20
  /**
15
21
  * Build CSS variable declarations for base palette overrides — context-independent.
@@ -21,7 +27,23 @@ function buildPaletteVars(baseColors) {
21
27
  if (!baseColors || Object.keys(baseColors).length === 0) return []
22
28
 
23
29
  const vars = []
24
- const palettes = generatePalettes(baseColors)
30
+ const colors = {}
31
+ for (const [name, value] of Object.entries(baseColors)) {
32
+ if (!isCssName(name)) continue
33
+ if (typeof value === 'string') {
34
+ if (isValidColor(value)) colors[name] = value
35
+ } else if (value && typeof value === 'object') {
36
+ if (value.base !== undefined) {
37
+ if (isValidColor(value.base)) colors[name] = value
38
+ } else {
39
+ // Pre-defined shades are written as given, so each is held to a value.
40
+ colors[name] = Object.fromEntries(
41
+ Object.entries(value).filter(([shade, v]) => /^\d+$/.test(shade) && isCssValue(v))
42
+ )
43
+ }
44
+ }
45
+ }
46
+ const palettes = generatePalettes(colors)
25
47
 
26
48
  for (const [name, shades] of Object.entries(palettes)) {
27
49
  for (const [level, value] of Object.entries(shades)) {
@@ -44,7 +66,9 @@ function buildElementVars(elements) {
44
66
  const vars = []
45
67
  for (const [token, value] of Object.entries(elements)) {
46
68
  if (value === '' || value == null) continue
47
- vars.push(` --${token}: ${normalizeTokenValue(value)};`)
69
+ const normalized = normalizeTokenValue(value)
70
+ if (!writable(token, normalized)) continue
71
+ vars.push(` --${token}: ${normalized};`)
48
72
  }
49
73
  return vars
50
74
  }
@@ -61,6 +85,7 @@ function buildFoundationVars(styles) {
61
85
  const vars = []
62
86
  for (const [name, value] of Object.entries(styles)) {
63
87
  if (value === '' || value == null || typeof value === 'object') continue
88
+ if (!writable(name, value)) continue
64
89
  vars.push(` --${name}: ${value};`)
65
90
  }
66
91
  return vars
@@ -151,7 +176,16 @@ export function buildSectionOverrides(blocks, appearance = {}) {
151
176
  css += buildSectionOverrides(block.childBlocks, appearance)
152
177
  }
153
178
  }
154
- return css
179
+ // Written into a <style> in a prerendered page's <head> — see values.js.
180
+ return closeToMarkup(css)
181
+ }
182
+
183
+ /**
184
+ * A section's id as a CSS identifier: each character outside letters, digits,
185
+ * `_` and `-` escaped, so an authored id selects its section and nothing else.
186
+ */
187
+ function cssIdent(id) {
188
+ return String(id).replace(/[^A-Za-z0-9_-]/gu, (c) => `\\${c.codePointAt(0).toString(16)} `)
155
189
  }
156
190
 
157
191
  /**
@@ -159,7 +193,7 @@ export function buildSectionOverrides(blocks, appearance = {}) {
159
193
  * light and under a dark scheme.
160
194
  */
161
195
  function buildBlockRules(block, appearance) {
162
- const selector = `#section-${block.stableId || block.id}`
196
+ const selector = `#section-${cssIdent(block.stableId || block.id)}`
163
197
  const isAuto = !block.themeName
164
198
  const always = []
165
199
  const light = []
package/src/values.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * What a theme value may be, before it is written into CSS or into a `<link>`.
3
+ *
4
+ * A theme is content — `theme.yml`, a section's `theme:` and its variables, and
5
+ * whatever an editor lets an author change of them. Its values are written into
6
+ * the stylesheet a prerendered page carries in its `<head>`, and its font URLs
7
+ * into `<link>` tags there. So a value has to stay a value: it may not end its
8
+ * declaration (`;`), open or close a rule (`{` `}`), end the element it sits in
9
+ * (`<` `>`), escape (`\`) or open a comment, and a URL is one a page may load. A
10
+ * value that is not is left out, with a warning naming it — never repaired,
11
+ * which would be a guess at what was meant.
12
+ *
13
+ * ⛔ Until 2026-10-14 every value was written as given, so a theme value holding
14
+ * `</style><script>…` ran as script on a prerendered page, and a font URL holding
15
+ * a `"` opened an attribute on its `<link>`.
16
+ *
17
+ * @module @uniweb/theming/values
18
+ */
19
+
20
+ const CONTROL = /[\u0000-\u001f\u007f]/
21
+ const ENDS_A_VALUE = /[;{}<>\\]|\/\*|\*\//
22
+ const RUNS = /expression\s*\(|javascript:|vbscript:|@import|behavior\s*:|-moz-binding/i
23
+ const URL_IN_A_VALUE = /url\(\s*(['"]?)(.*?)\1\s*\)/gi
24
+
25
+ /**
26
+ * Whether a URL is one a page may load: relative, or `http:` / `https:`. It may
27
+ * hold no space, quote, parenthesis, angle bracket or backslash, so it cannot
28
+ * leave the `url(…)` or the attribute it is written into.
29
+ *
30
+ * @param {string} url
31
+ * @returns {boolean}
32
+ */
33
+ export function isLoadableUrl(url) {
34
+ if (typeof url !== 'string' || !url || CONTROL.test(url) || /[\s"'()<>\\]/.test(url)) return false
35
+ const scheme = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)
36
+ return !scheme || /^https?$/i.test(scheme[1])
37
+ }
38
+
39
+ /**
40
+ * Whether a value may be written as a CSS value: a finite number, a boolean —
41
+ * written `true` or `false`, as an editor's switch saves a variable — or a string
42
+ * that cannot end its declaration, its rule or its element, and whose every
43
+ * `url(…)` is loadable.
44
+ *
45
+ * ⛔ *Until 2026-10-15 a boolean was refused, so a switch's variable was left out
46
+ * of the stylesheet — though it can end nothing, and was written as `true` before
47
+ * this rule existed.*
48
+ *
49
+ * @param {*} value
50
+ * @returns {boolean}
51
+ */
52
+ export function isCssValue(value) {
53
+ if (typeof value === 'number') return Number.isFinite(value)
54
+ if (typeof value === 'boolean') return true
55
+ if (typeof value !== 'string') return false
56
+ if (CONTROL.test(value) || ENDS_A_VALUE.test(value) || RUNS.test(value)) return false
57
+ for (const [, , url] of value.matchAll(URL_IN_A_VALUE)) {
58
+ if (!isLoadableUrl(url)) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ /**
64
+ * Whether a name may be written where CSS expects one of ours — a custom
65
+ * property after `--`, a style name in `span[…]`, a font role: a letter or `_`,
66
+ * then letters, digits, `_` and `-`.
67
+ *
68
+ * @param {string} name
69
+ * @returns {boolean}
70
+ */
71
+ export function isCssName(name) {
72
+ return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)
73
+ }
74
+
75
+ /**
76
+ * Whether a name may be written as a CSS property — `color`, `font-weight`,
77
+ * `-webkit-text-stroke`.
78
+ *
79
+ * @param {string} name
80
+ * @returns {boolean}
81
+ */
82
+ export function isCssProperty(name) {
83
+ return typeof name === 'string' && /^-?[a-z][a-z0-9-]*$/.test(name)
84
+ }
85
+
86
+ /**
87
+ * Escape a string for a double-quoted HTML attribute value.
88
+ *
89
+ * @param {*} value
90
+ * @returns {string}
91
+ */
92
+ export function escapeAttribute(value) {
93
+ return String(value)
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/</g, '&lt;')
96
+ .replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;')
98
+ .replace(/'/g, '&#39;')
99
+ }
100
+
101
+ /**
102
+ * The last line: a stylesheet leaves here with no `<` in it, so whatever got
103
+ * past the checks above cannot end the `<style>` element it is written into.
104
+ * Valid CSS has no `<` outside a string, and inside one `\3c ` is the same
105
+ * character.
106
+ *
107
+ * @param {string} css
108
+ * @returns {string}
109
+ */
110
+ export function closeToMarkup(css) {
111
+ return typeof css === 'string' ? css.replace(/</g, '\\3c ') : css
112
+ }
113
+
114
+ /**
115
+ * Keep the entries of a name → value map that may be written, and say what was
116
+ * left out.
117
+ *
118
+ * @param {Object} map
119
+ * @param {string} where - what the map is, for a warning (`contexts.light`)
120
+ * @param {string[]} warnings - where a warning is added
121
+ * @param {(name: string) => boolean} [isName]
122
+ * @returns {Object} the entries that may be written
123
+ */
124
+ export function keepWritable(map, where, warnings, isName = isCssName) {
125
+ const kept = {}
126
+ for (const [name, value] of Object.entries(map || {})) {
127
+ if (!isName(name)) {
128
+ warnings.push(`theme: ${where}: \`${name}\` is not a name a stylesheet can hold — left out`)
129
+ } else if (value !== null && value !== undefined && value !== '' && !isCssValue(value)) {
130
+ warnings.push(`theme: ${where}.${name}: ${JSON.stringify(value)} is not a CSS value — left out`)
131
+ } else {
132
+ kept[name] = value
133
+ }
134
+ }
135
+ return kept
136
+ }