@uniweb/theming 1.1.0 → 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 +1 -1
- package/src/css-generator.js +9 -5
- package/src/index.js +4 -0
- package/src/processor.js +107 -13
- package/src/section-overrides.js +39 -5
- package/src/values.js +136 -0
package/package.json
CHANGED
package/src/css-generator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 } =
|
|
579
|
-
foundationVars,
|
|
580
|
-
|
|
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
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
package/src/section-overrides.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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, '&')
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|