@halftone-ds/tokens 1.2.0 → 1.3.1
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/README.md +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +187 -28
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +110 -22
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +40 -2
package/lib/theme/recipe.mjs
CHANGED
|
@@ -104,6 +104,74 @@ export const typeScale = ({ size, leading, flow, measure }) => ({
|
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
/** The Text variants both kits draw (components/ui/text.tsx on mobile; the type-* utilities on the web). */
|
|
108
|
+
export const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The text styles as the kits draw them at the default typeset (16px, leading 1.75), from the mobile kit's
|
|
112
|
+
* components/ui/text.tsx and Tailwind's sizes: size px, leading × size, tracking em. `leading: 'prose'` is the typeset's own leading
|
|
113
|
+
* (Text p's leading-prose); every other leading scales with the typeset's (leading / 1.75), and sizes with
|
|
114
|
+
* size / 16. `small` is leading-none, which never scales.
|
|
115
|
+
*/
|
|
116
|
+
export const TEXT_DEFAULTS = {
|
|
117
|
+
h1: { font: 'heading', weight: 800, size: 36, leading: 40 / 36, tracking: -0.025 },
|
|
118
|
+
h2: { font: 'heading', weight: 600, size: 30, leading: 36 / 30, tracking: -0.025 },
|
|
119
|
+
h3: { font: 'heading', weight: 600, size: 24, leading: 32 / 24, tracking: -0.025 },
|
|
120
|
+
h4: { font: 'heading', weight: 600, size: 20, leading: 28 / 20, tracking: -0.025 },
|
|
121
|
+
lead: { font: 'body', weight: 400, size: 20, leading: 28 / 20, tracking: 0 },
|
|
122
|
+
p: { font: 'body', weight: 400, size: 16, leading: 'prose', tracking: 0 },
|
|
123
|
+
large: { font: 'body', weight: 600, size: 18, leading: 28 / 18, tracking: 0 },
|
|
124
|
+
small: { font: 'body', weight: 500, size: 14, leading: 1, tracking: 0 },
|
|
125
|
+
muted: { font: 'body', weight: 400, size: 14, leading: 20 / 14, tracking: 0 },
|
|
126
|
+
code: { font: 'mono', weight: 600, size: 14, leading: 20 / 14, tracking: 0 },
|
|
127
|
+
blockquote: { font: 'body', weight: 400, size: 16, leading: 24 / 16, tracking: 0 },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const FONT_REF = /^\{primitive\.font\.family\.(heading|body|mono)\}$/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A system's edited text styles from a semantic.type group: a theme's ({ h1: { size, … } }) or a token file's
|
|
134
|
+
* DTCG one ({ h1: { size: { $value } } }). The font is a reference to a role's family; a style's other keys
|
|
135
|
+
* are numbers. The typeset's own numbers (size, leading, flow, measure) are skipped.
|
|
136
|
+
* → { h1: { size, leading, weight, tracking, font: 'heading' } }
|
|
137
|
+
*/
|
|
138
|
+
export function textEdits(group = {}) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const name of TEXT_STYLES) {
|
|
141
|
+
const style = group?.[name];
|
|
142
|
+
if (!style || typeof style !== 'object' || '$value' in style) continue;
|
|
143
|
+
const value = (v) => (v && typeof v === 'object' && '$value' in v ? v.$value : v);
|
|
144
|
+
const edit = {};
|
|
145
|
+
for (const key of ['size', 'leading', 'weight', 'tracking']) if (typeof value(style[key]) === 'number') edit[key] = value(style[key]);
|
|
146
|
+
const font = FONT_REF.exec(String(value(style.font) ?? ''));
|
|
147
|
+
if (font) edit.font = font[1];
|
|
148
|
+
out[name] = edit;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every text style as drawn at a typeset (typeScale), edits on top, unrounded:
|
|
155
|
+
* { h1: { font, weight, size: px, leading: × size, tracking: em, lineHeight: px, edited: bool } }
|
|
156
|
+
* Sizes scale by the typeset's size / 16 and line heights by its leading / 1.75; Text p takes the typeset's
|
|
157
|
+
* leading itself and Text small (leading-none) never scales. With no edits this is exactly what the kits drew
|
|
158
|
+
* with Tailwind's text-* classes.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveTextStyles(type, edits = {}) {
|
|
161
|
+
return Object.fromEntries(
|
|
162
|
+
TEXT_STYLES.map((name) => {
|
|
163
|
+
const d = TEXT_DEFAULTS[name];
|
|
164
|
+
const leading = d.leading === 'prose' ? type.leading : d.leading === 1 ? 1 : d.leading * type.leadingFactor;
|
|
165
|
+
const edit = edits[name] ?? {};
|
|
166
|
+
const style = { font: d.font, weight: d.weight, size: d.size * type.scale, leading, tracking: d.tracking, ...edit, edited: Object.keys(edit).length > 0 };
|
|
167
|
+
// The line height in px, multiplied in the order the text-* classes always were (so it rounds the same).
|
|
168
|
+
const px = Math.round(d.size * (d.leading === 'prose' ? 1 : d.leading) * 1e6) / 1e6;
|
|
169
|
+
style.lineHeight = 'size' in edit || 'leading' in edit ? style.size * style.leading : d.leading === 'prose' ? px * type.scale * type.leading : d.leading === 1 ? style.size : px * type.scale * type.leadingFactor;
|
|
170
|
+
return [name, style];
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
107
175
|
const s = (x, y, blur, spread, color) => ({ offsetX: x, offsetY: y, blur, spread, color });
|
|
108
176
|
/**
|
|
109
177
|
* Three elevation levels per depth (DTCG shadow values, light and dark): 1 for controls and cards,
|
package/lib/theme/semantic.mjs
CHANGED
|
@@ -13,8 +13,18 @@ const p = (ramp, step) => `{primitive.color.${ramp}.${step}}`;
|
|
|
13
13
|
const DARK_STEP = { 600: '500', 700: '400', 800: '300', 900: '200', 950: '100' };
|
|
14
14
|
export const darkStep = (anchor) => DARK_STEP[anchor] ?? anchor;
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The status tones' steps, as offsets from the step their seed sits on (600 for the curated ramps the kits
|
|
18
|
+
* ship), light and dark: a status seed that lands on another step moves its tone with it, as the brand does.
|
|
19
|
+
*/
|
|
20
|
+
const STATUS_STEPS = { danger: [0, -1], success: [1, -1], warning: [1, -2], info: [1, -1] };
|
|
21
|
+
const status = (ramp, anchor = '600') => ({ light: p(ramp, shift(anchor, STATUS_STEPS[ramp][0])), dark: p(ramp, shift(anchor, STATUS_STEPS[ramp][1])) });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* { name: { light: ref, dark: ref } } in the order the kit's tokens.json lists them. `anchors` holds the step
|
|
25
|
+
* each status seed sits on (`{ danger: '500' }`); a status without one uses its 600, the curated ramps' seed.
|
|
26
|
+
*/
|
|
27
|
+
export function semanticColors(anchor = '600', anchors = {}) {
|
|
18
28
|
const brand = { light: b(anchor), dark: b(darkStep(anchor)) };
|
|
19
29
|
const onDark = { light: n(0), dark: n(950) };
|
|
20
30
|
return {
|
|
@@ -32,13 +42,13 @@ export function semanticColors(anchor = '600') {
|
|
|
32
42
|
'muted-foreground': { light: n(600), dark: n(400) },
|
|
33
43
|
accent: { light: n(100), dark: n(800) },
|
|
34
44
|
'accent-foreground': { light: n(900), dark: n(50) },
|
|
35
|
-
destructive:
|
|
45
|
+
destructive: status('danger', anchors.danger),
|
|
36
46
|
'destructive-foreground': onDark,
|
|
37
|
-
success:
|
|
47
|
+
success: status('success', anchors.success),
|
|
38
48
|
'success-foreground': onDark,
|
|
39
|
-
warning:
|
|
49
|
+
warning: status('warning', anchors.warning),
|
|
40
50
|
'warning-foreground': onDark,
|
|
41
|
-
info:
|
|
51
|
+
info: status('info', anchors.info),
|
|
42
52
|
'info-foreground': onDark,
|
|
43
53
|
border: { light: n(200), dark: n(800) },
|
|
44
54
|
input: { light: n(200), dark: n(800) },
|
|
@@ -59,7 +69,9 @@ export function semanticColors(anchor = '600') {
|
|
|
59
69
|
};
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
function shift(step, by) {
|
|
73
|
+
return STEPS[Math.min(STEPS.length - 1, Math.max(0, STEPS.indexOf(step) + by))];
|
|
74
|
+
}
|
|
63
75
|
|
|
64
76
|
/**
|
|
65
77
|
* The colours a recipe's styles add (button, field), from the base colours after their contrast fix, so a moved
|
package/lib/theme/shadcn.mjs
CHANGED
|
@@ -247,7 +247,7 @@ export function shadcnTheme(theme, { name, title: label, description, fontsUrl =
|
|
|
247
247
|
$schema: 'https://ui.shadcn.com/schema/registry-item.json',
|
|
248
248
|
name: name ?? `halftone-${theme.code}`,
|
|
249
249
|
title: label ?? `Halftone theme ${theme.code}`,
|
|
250
|
-
description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/
|
|
250
|
+
description: description ?? `A Halftone theme (${theme.code}): colours for light and dark, radius, shadows and fonts. halftonedesignsystem.com/studio/?t=${theme.code}`,
|
|
251
251
|
type: 'registry:theme',
|
|
252
252
|
...(dependencies.length ? { registryDependencies: dependencies } : {}),
|
|
253
253
|
cssVars: {
|
package/lib/theme/vars.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 'oklch' Tailwind 4 / shadcn web (`oklch(0.546 0.2152 262.88)`) — the web kit and the docs site
|
|
6
6
|
*/
|
|
7
7
|
import { hexToHslTriplet, hexToOklch } from '../color.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { componentVars } from './components.mjs';
|
|
9
|
+
import { extraList, extraTextStyles } from './extras.mjs';
|
|
10
|
+
import { resolveTextStyles, shadowCss, textEdits } from './recipe.mjs';
|
|
9
11
|
|
|
10
12
|
export const rem = (px) => (px >= 9999 ? '9999px' : `${px / 16}rem`);
|
|
11
13
|
|
|
@@ -20,10 +22,40 @@ export const typeVars = (type) => ({
|
|
|
20
22
|
'--typeset-measure': `${type.measure}ch`,
|
|
21
23
|
});
|
|
22
24
|
|
|
25
|
+
const fixed = (n, places = 4) => Number(n.toFixed(places));
|
|
26
|
+
|
|
27
|
+
/** A theme's text styles as drawn (recipe.mjs resolveTextStyles): its typeset, its edited styles on top. */
|
|
28
|
+
export const themeTextStyles = (theme) => resolveTextStyles(theme.type, textEdits(theme.semantic?.type));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The text styles' variables, --type-<style>-{size,leading,weight,tracking,font}, from resolveTextStyles. Literal
|
|
32
|
+
* values at the theme's typeset, in each kit's units:
|
|
33
|
+
* 'hsl' mobile (NativeWind): size and line height in rem (native line heights are lengths); the font is
|
|
34
|
+
* the role, which components/ui/text.tsx picks in JS, so no variable. Tracking is em on native too:
|
|
35
|
+
* NativeWind resolves it against the style's own size (its preset's tracking-* are fixed px instead)
|
|
36
|
+
* 'oklch' web: rem and a unitless line height; the font is the role's family variable. `font(role)` writes
|
|
37
|
+
* it (the build adds fallbacks; a live theme always sets --kit-font-*)
|
|
38
|
+
*/
|
|
39
|
+
export function textStyleVars(styles, format, font = (role) => `var(--kit-font-${role})`) {
|
|
40
|
+
const vars = {};
|
|
41
|
+
// Mobile rounds rem to 4 places, as nativewind3's text-* sizes always were.
|
|
42
|
+
const length = (px) => (format === 'hsl' ? `${fixed(px / 16)}rem` : rem(fixed(px)));
|
|
43
|
+
for (const [name, s] of Object.entries(styles)) {
|
|
44
|
+
const v = `--type-${name}`;
|
|
45
|
+
vars[`${v}-size`] = length(s.size);
|
|
46
|
+
vars[`${v}-leading`] = format === 'hsl' ? length(s.lineHeight ?? s.size * s.leading) : String(fixed(s.leading, 6));
|
|
47
|
+
vars[`${v}-weight`] = String(s.weight);
|
|
48
|
+
vars[`${v}-tracking`] = `${s.tracking}em`;
|
|
49
|
+
if (format !== 'hsl') vars[`${v}-font`] = font(s.font);
|
|
50
|
+
}
|
|
51
|
+
return vars;
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/**
|
|
24
55
|
* The button and field styles' variables (both formats), from a theme's or a token file's { button, field }
|
|
25
|
-
* (generate.mjs, resolve.mjs). Widths
|
|
26
|
-
*
|
|
56
|
+
* (generate.mjs, resolve.mjs). Widths are numbers on the light block; the field's fill opacity is per mode. A
|
|
57
|
+
* field's corners and padding are the component layer's (componentVars: --field-radius, --field-radius-b,
|
|
58
|
+
* --field-x).
|
|
27
59
|
*/
|
|
28
60
|
export function styleVars({ button, field }, format) {
|
|
29
61
|
return {
|
|
@@ -34,9 +66,6 @@ export function styleVars({ button, field }, format) {
|
|
|
34
66
|
'--field-alpha': String(field.alpha.light),
|
|
35
67
|
'--field-border-w': `${field.border}px`,
|
|
36
68
|
'--field-edge-w': `${field.edge}px`,
|
|
37
|
-
'--field-radius': rem(field.radius),
|
|
38
|
-
'--field-radius-b': rem(field.radiusBottom),
|
|
39
|
-
'--field-x': field.inset ? (format === 'hsl' ? '1rem' : 'calc(var(--spacing) * 3)') : '0px',
|
|
40
69
|
// Web: a boxed field keeps shadcn's shadow-xs; a filled or underline field has none.
|
|
41
70
|
...(format === 'hsl' ? {} : { '--field-shadow': field.border ? 'var(--shadow-1)' : '0 0 #0000' }),
|
|
42
71
|
},
|
|
@@ -44,7 +73,29 @@ export function styleVars({ button, field }, format) {
|
|
|
44
73
|
};
|
|
45
74
|
}
|
|
46
75
|
|
|
47
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* A system's extras as variables (lib/theme/extras.mjs): --<colour> (a ramp's --<name>-<step>), --radius-<name>,
|
|
78
|
+
* --shadow-<name> per mode, --border-<name>-width and --type-<name>-*. `font` as textStyleVars takes it.
|
|
79
|
+
*/
|
|
80
|
+
export function extraVars(list, format, font) {
|
|
81
|
+
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
82
|
+
const light = {};
|
|
83
|
+
const dark = {};
|
|
84
|
+
for (const c of list.color) {
|
|
85
|
+
light[`--${c.name}`] = convert(c.light);
|
|
86
|
+
dark[`--${c.name}`] = convert(c.dark);
|
|
87
|
+
}
|
|
88
|
+
for (const r of list.radius) light[`--radius-${r.name}`] = rem(r.px);
|
|
89
|
+
for (const s of list.shadow) {
|
|
90
|
+
light[`--shadow-${s.name}`] = shadowCss(s.light);
|
|
91
|
+
dark[`--shadow-${s.name}`] = shadowCss(s.dark);
|
|
92
|
+
}
|
|
93
|
+
for (const b of list.border) light[`--border-${b.name}-width`] = `${b.width}px`;
|
|
94
|
+
Object.assign(light, textStyleVars(extraTextStyles(list), format, font));
|
|
95
|
+
return { light, dark };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** { light: { '--primary': '…', '--radius': '0.625rem' }, dark: { … } } (a system's extras too) */
|
|
48
99
|
export function themeVars(theme, format) {
|
|
49
100
|
const convert = format === 'hsl' ? hexToHslTriplet : hexToOklch;
|
|
50
101
|
const mode = (m) => {
|
|
@@ -56,10 +107,12 @@ export function themeVars(theme, format) {
|
|
|
56
107
|
vars['--icon-stroke'] = String(theme.stroke);
|
|
57
108
|
vars['--density'] = String(theme.density.scale);
|
|
58
109
|
for (const size of ['sm', 'md', 'lg', 'x']) vars[`--control-${size}`] = `${theme.density[size]}px`;
|
|
59
|
-
Object.assign(vars, typeVars(theme.type));
|
|
110
|
+
Object.assign(vars, typeVars(theme.type), textStyleVars(themeTextStyles(theme), format));
|
|
60
111
|
}
|
|
61
112
|
Object.assign(vars, styleVars(theme, format)[m]);
|
|
113
|
+
if (theme.components) Object.assign(vars, componentVars(theme.components, format, convert)[m]);
|
|
62
114
|
for (const [level, value] of Object.entries(theme.semantic.shadow)) vars[`--shadow-${level}`] = shadowCss(value[m]);
|
|
115
|
+
if (theme.extra) Object.assign(vars, extraVars(extraList(theme.extra), format)[m]);
|
|
63
116
|
return vars;
|
|
64
117
|
};
|
|
65
118
|
return { light: mode('light'), dark: mode('dark') };
|
package/lib/theme-cli.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* `halftone-tokens theme …` — commit a theme from the docs picker into tokens.json.
|
|
3
3
|
*
|
|
4
4
|
* theme apply <theme> [--dry-run] [--force]
|
|
5
|
-
* write the theme into tokens.json (only the paths a theme owns) and rebuild
|
|
5
|
+
* write the theme into tokens.json (only the paths a theme owns) and rebuild; a system document is
|
|
6
|
+
* recorded in tokens.json's $extensions (without its brand images)
|
|
6
7
|
* theme show [--check] the applied theme, its code and any hand edits since (drift)
|
|
7
8
|
* --check: exit 1 if the recorded theme no longer reproduces (CI)
|
|
8
9
|
* theme presets list the presets and their codes
|
|
@@ -12,19 +13,22 @@
|
|
|
12
13
|
* theme shadcn <theme> [--out file] [--fonts-url url]
|
|
13
14
|
* the theme as a shadcn registry:theme item (stdout, or a file), for `npx shadcn add`
|
|
14
15
|
*
|
|
15
|
-
* <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json,
|
|
16
|
-
* (ui.shadcn.com/create, e.g. b5owWMfJ8l),
|
|
17
|
-
* or drops is listed on stderr.
|
|
16
|
+
* <theme> is a Halftone code (ht1-…, ht2-…, ht3-…), a preset name, a recipe .json, a system document
|
|
17
|
+
* (name.halftone.json, from the Studio), or a shadcn/ui preset code (ui.shadcn.com/create, e.g. b5owWMfJ8l),
|
|
18
|
+
* which becomes the nearest Halftone recipe: what it approximates or drops is listed on stderr.
|
|
18
19
|
*/
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
22
23
|
|
|
23
24
|
import { cvdWarnings, describeCvdWarnings, MIN_DELTA_E } from './cvd.mjs';
|
|
24
|
-
import { FONTS } from './theme/fonts.mjs';
|
|
25
|
+
import { FONTS, fontByFamily } from './theme/fonts.mjs';
|
|
26
|
+
import { isSystem, normalizeSystem, SystemError, systemOverrides } from './system/schema.mjs';
|
|
27
|
+
import { systemCode } from './system/generate.mjs';
|
|
25
28
|
|
|
26
29
|
import {
|
|
27
30
|
applyRecipe,
|
|
31
|
+
applySystem,
|
|
28
32
|
decodeRecipe,
|
|
29
33
|
encodeRecipe,
|
|
30
34
|
fontById,
|
|
@@ -37,6 +41,7 @@ import {
|
|
|
37
41
|
recipeFromShadcn,
|
|
38
42
|
SHADCN_FONTS_URL,
|
|
39
43
|
shadcnTheme,
|
|
44
|
+
THEME_EXTENSION,
|
|
40
45
|
themeStatus,
|
|
41
46
|
} from './theme/index.mjs';
|
|
42
47
|
|
|
@@ -51,8 +56,55 @@ function printNotes(code, notes) {
|
|
|
51
56
|
console.error(`From shadcn preset ${code}, approximated or not carried:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/** { recipe, system? }: a system document when <theme> is one (name.halftone.json), else a recipe. */
|
|
60
|
+
function readTheme(arg, root) {
|
|
61
|
+
// resolve: an absolute path stays itself (join would put it under root).
|
|
62
|
+
const file = arg ? resolve(root, arg) : '';
|
|
63
|
+
if (arg?.endsWith('.json') && existsSync(file)) {
|
|
64
|
+
let json;
|
|
65
|
+
try {
|
|
66
|
+
json = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
fail(`${arg} is not valid JSON: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (isSystem(json)) {
|
|
71
|
+
try {
|
|
72
|
+
const system = normalizeSystem(json);
|
|
73
|
+
return { recipe: system.recipe, system };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof SystemError) fail(`${arg}: ${error.message}`);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { recipe: readRecipe(arg, root) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Why a system has no code: its overrides, briefly. */
|
|
84
|
+
function noCode(system) {
|
|
85
|
+
const overrides = systemOverrides(system);
|
|
86
|
+
const shown = overrides.slice(0, 6).join(', ') + (overrides.length > 6 ? `, and ${overrides.length - 6} more` : '');
|
|
87
|
+
return `"${system.name}" has ${overrides.length} override(s) a theme code can't carry (${shown}). Share its .halftone.json instead.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A system's overrides, grouped by section, for `show` and `apply`. */
|
|
91
|
+
function describeOverrides(system) {
|
|
92
|
+
const bySection = {};
|
|
93
|
+
for (const path of systemOverrides(system)) {
|
|
94
|
+
const [section, ...rest] = path.split('.');
|
|
95
|
+
(bySection[section] ??= []).push(rest.join('.'));
|
|
96
|
+
}
|
|
97
|
+
return Object.entries(bySection).map(([section, list]) => `${section}: ${list.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printWarnings(warnings) {
|
|
101
|
+
if (!warnings?.length) return;
|
|
102
|
+
console.log(`\nBelow WCAG AA because of colours chosen by hand (warnings; the fixer leaves hand-picked colours alone):`);
|
|
103
|
+
for (const w of warnings) console.log(` ${w.mode.padEnd(5)} ${w.label.padEnd(40)} ${w.ratio}:1 (needs ${w.threshold})${w.locked.length ? ` · chosen: ${w.locked.join(', ')}` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
54
106
|
function readRecipe(arg, root) {
|
|
55
|
-
if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json or a shadcn preset code', 2);
|
|
107
|
+
if (!arg) fail('say which theme: a code (ht1-…, ht2-… or ht3-…), a preset name, a recipe .json, a system .halftone.json or a shadcn preset code', 2);
|
|
56
108
|
if (isThemeCode(arg)) {
|
|
57
109
|
try {
|
|
58
110
|
return decodeRecipe(arg);
|
|
@@ -62,7 +114,7 @@ function readRecipe(arg, root) {
|
|
|
62
114
|
}
|
|
63
115
|
const preset = presetById(arg.toLowerCase());
|
|
64
116
|
if (preset) return normalizeRecipe({ ...preset.recipe, preset: preset.id });
|
|
65
|
-
const file =
|
|
117
|
+
const file = resolve(root, arg);
|
|
66
118
|
if (arg.endsWith('.json') && existsSync(file)) return normalizeRecipe(JSON.parse(readFileSync(file, 'utf8')));
|
|
67
119
|
// Last, so a preset name that happens to look like one (bold, brutalist) stays Halftone's preset.
|
|
68
120
|
const shadcn = isShadcnPresetCode(arg) ? decodeShadcnPreset(arg) : null;
|
|
@@ -74,17 +126,20 @@ function readRecipe(arg, root) {
|
|
|
74
126
|
fail(`"${arg}" is not a theme code, a preset (${PRESETS.map((p) => p.id).join(', ')}), a recipe file or a shadcn preset code`, 2);
|
|
75
127
|
}
|
|
76
128
|
|
|
77
|
-
|
|
129
|
+
/** A recipe in a few lines. `families`: a system's own fonts (any Google Fonts family), which win over the recipe's. */
|
|
130
|
+
const describe = (input, families = {}) => {
|
|
78
131
|
// A recipe recorded by an older halftone-tokens has no typeset or styles: it gets the defaults.
|
|
79
132
|
const recipe = normalizeRecipe(input);
|
|
80
|
-
const font = (
|
|
133
|
+
const font = (role) => {
|
|
134
|
+
if (families[role]) return families[role];
|
|
135
|
+
const id = recipe.font[role];
|
|
81
136
|
const f = fontById(id);
|
|
82
137
|
return f?.system ? (f.category === 'mono' ? 'system mono' : 'system') : (f?.family ?? id);
|
|
83
138
|
};
|
|
84
139
|
return [
|
|
85
140
|
`brand ${recipe.brand} · neutral ${recipe.neutral} · radius ${recipe.radius} · controls ${recipe.controls}`,
|
|
86
|
-
`type ${font(
|
|
87
|
-
`typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font(
|
|
141
|
+
`type ${font('heading')} / ${font('body')} · stroke ${recipe.stroke} · depth ${recipe.depth} · density ${recipe.density} · border ${recipe.border}`,
|
|
142
|
+
`typeset ${recipe.size}px · leading ${recipe.leading} · flow ${recipe.flow} · measure ${recipe.measure}ch · mono ${font('mono')}`,
|
|
88
143
|
`styles: ${recipe.button} buttons · ${recipe.field} fields`,
|
|
89
144
|
].join('\n ');
|
|
90
145
|
};
|
|
@@ -98,7 +153,8 @@ function installFonts(root, config, theme, flags) {
|
|
|
98
153
|
if (config.targets?.fonts?.platform !== 'expo') return;
|
|
99
154
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
100
155
|
const have = new Set(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((d) => d.startsWith('@expo-google-fonts/')));
|
|
101
|
-
|
|
156
|
+
// The theme's families: curated ones, or any Google Fonts family a system names.
|
|
157
|
+
const ids = Object.values(theme.fonts).filter(Boolean).map(fontByFamily).filter((f) => !f.system).map((f) => f.id);
|
|
102
158
|
const need = [...new Set(ids)].map((id) => `@expo-google-fonts/${id}`);
|
|
103
159
|
const missing = need.filter((p) => !have.has(p));
|
|
104
160
|
const unused = [...have].filter((p) => !need.includes(p) && FONTS.some((f) => `@expo-google-fonts/${f.id}` === p));
|
|
@@ -114,6 +170,18 @@ function installFonts(root, config, theme, flags) {
|
|
|
114
170
|
if (unused.length) console.log(`\nNo longer used by the theme (remove when you like): npm uninstall ${unused.join(' ')}`);
|
|
115
171
|
}
|
|
116
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A kit's registry:build writes which components draw which token (registry/generated/token-usage.json and the docs
|
|
175
|
+
* from it), and that follows the colour roles a system maps, so applying one rebuilds it: its --check stays green.
|
|
176
|
+
*/
|
|
177
|
+
function rebuildRegistry(root) {
|
|
178
|
+
const file = join(root, 'package.json');
|
|
179
|
+
if (!existsSync(file) || !JSON.parse(readFileSync(file, 'utf8')).scripts?.['registry:build']) return;
|
|
180
|
+
console.log('\nregistry:build (token usage follows the colour roles)…');
|
|
181
|
+
const r = spawnSync('npm', ['run', '--silent', 'registry:build'], { cwd: root, stdio: 'inherit' });
|
|
182
|
+
if (r.status !== 0) console.log('registry:build failed: run `npm run registry:build` yourself.');
|
|
183
|
+
}
|
|
184
|
+
|
|
117
185
|
export async function themeCommand({ positional, flags, root, sourcePath, config, build }) {
|
|
118
186
|
const [sub = 'show', arg] = positional;
|
|
119
187
|
const tokens = () => JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
@@ -124,7 +192,9 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
124
192
|
}
|
|
125
193
|
|
|
126
194
|
if (sub === 'encode') {
|
|
127
|
-
|
|
195
|
+
const { recipe, system } = readTheme(arg, root);
|
|
196
|
+
if (system && !systemCode(system)) fail(noCode(system));
|
|
197
|
+
console.log(encodeRecipe(recipe));
|
|
128
198
|
return;
|
|
129
199
|
}
|
|
130
200
|
|
|
@@ -166,6 +236,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
166
236
|
if (sub === 'resolve') {
|
|
167
237
|
const status = themeStatus(tokens());
|
|
168
238
|
if (!status.applied) fail('no theme recorded in tokens.json, so there is no code for it. Apply one with `halftone-tokens theme apply <theme>`.');
|
|
239
|
+
if (!status.code) fail(`the applied system ${noCode(status.system)} Its document is recorded in tokens.json ($extensions).`);
|
|
169
240
|
if (status.drift.length) fail(`tokens.json was edited by hand since ${status.code} was applied (${status.drift.length} token(s)); no code reproduces it. \`theme show\` lists them.`);
|
|
170
241
|
if (flags.json) console.log(JSON.stringify({ code: status.code, recipe: status.recipe }, null, 2));
|
|
171
242
|
else console.log(status.code);
|
|
@@ -173,8 +244,8 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
173
244
|
}
|
|
174
245
|
|
|
175
246
|
if (sub === 'shadcn') {
|
|
176
|
-
const recipe =
|
|
177
|
-
const { theme } = applyRecipe(tokens(), recipe);
|
|
247
|
+
const { recipe, system } = readTheme(arg, root);
|
|
248
|
+
const { theme } = system ? applySystem(tokens(), system) : applyRecipe(tokens(), recipe);
|
|
178
249
|
const item = shadcnTheme(theme, {
|
|
179
250
|
...(recipe.preset ? { name: `halftone-${recipe.preset}`, title: `Halftone ${presetById(recipe.preset)?.name} theme` } : {}),
|
|
180
251
|
fontsUrl: typeof flags['fonts-url'] === 'string' ? flags['fonts-url'] : SHADCN_FONTS_URL,
|
|
@@ -194,8 +265,16 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
194
265
|
if (flags.check) process.exit(0);
|
|
195
266
|
return;
|
|
196
267
|
}
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
if (status.system) {
|
|
269
|
+
const { system } = status;
|
|
270
|
+
const overrides = describeOverrides(system);
|
|
271
|
+
console.log(`System "${system.name}"${status.code ? ` ${status.code}` : ' (no theme code: it has overrides)'}\n ${describe(status.recipe, system.fonts)}`);
|
|
272
|
+
if (overrides.length) console.log(`\nOverrides:\n${overrides.map((o) => ` ${o}`).join('\n')}`);
|
|
273
|
+
printWarnings(tokens().$extensions?.[THEME_EXTENSION]?.warnings);
|
|
274
|
+
} else {
|
|
275
|
+
const name = status.recipe.preset ? ` (${presetById(status.recipe.preset)?.name ?? status.recipe.preset})` : '';
|
|
276
|
+
console.log(`Theme ${status.code}${name}\n ${describe(status.recipe)}`);
|
|
277
|
+
}
|
|
199
278
|
if (status.drift.length) {
|
|
200
279
|
console.log(`\nEdited by hand since it was applied (${status.drift.length}):\n${status.drift.map((p) => ` ${p}`).join('\n')}`);
|
|
201
280
|
if (flags.check) fail('tokens.json no longer matches its recorded theme. Re-apply it, or apply with --force to record the edits away.');
|
|
@@ -204,7 +283,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
204
283
|
}
|
|
205
284
|
|
|
206
285
|
if (sub === 'apply') {
|
|
207
|
-
const recipe =
|
|
286
|
+
const { recipe, system } = readTheme(arg, root);
|
|
208
287
|
const current = tokens();
|
|
209
288
|
const status = themeStatus(current);
|
|
210
289
|
const blocked = !status.applied
|
|
@@ -214,15 +293,23 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
214
293
|
? `these theme tokens were edited by hand since ${status.code} was applied:\n${status.drift.map((p) => ` ${p}`).join('\n')}\nRe-run with --force to overwrite them.`
|
|
215
294
|
: null;
|
|
216
295
|
if (blocked && !flags.force && !flags['dry-run']) fail(blocked);
|
|
217
|
-
const { tokens: next, theme } = applyRecipe(current, recipe);
|
|
218
|
-
|
|
296
|
+
const { tokens: next, theme } = system ? applySystem(current, system) : applyRecipe(current, recipe);
|
|
297
|
+
if (system) {
|
|
298
|
+
const code = systemCode(system);
|
|
299
|
+
const overrides = describeOverrides(system);
|
|
300
|
+
console.log(`System "${system.name}"${code ? ` ${code}` : ''}\n ${describe(theme.recipe, system.fonts)}`);
|
|
301
|
+
if (overrides.length) console.log(`\nOverrides (no theme code carries these; tokens.json records the document):\n${overrides.map((o) => ` ${o}`).join('\n')}`);
|
|
302
|
+
} else console.log(`Theme ${theme.code}${recipe.preset ? ` (${presetById(recipe.preset)?.name})` : ''}\n ${describe(theme.recipe)}`);
|
|
219
303
|
if (theme.adjustments.length) {
|
|
220
304
|
console.log(`\nAdjusted for contrast (WCAG AA), hue kept:`);
|
|
221
305
|
for (const a of theme.adjustments) console.log(` ${a.mode.padEnd(5)} ${a.token.padEnd(28)} ${a.from} → ${a.to}`);
|
|
222
306
|
}
|
|
307
|
+
printWarnings(theme.warnings);
|
|
223
308
|
const cvd = cvdWarnings(theme.resolved);
|
|
224
309
|
if (cvd.length) {
|
|
225
|
-
|
|
310
|
+
// Counted as the Studio's Accessibility tab counts them: pairs of tones, each listed in every view it's close in.
|
|
311
|
+
const pairs = new Set(cvd.map((w) => `${w.a}/${w.b}`)).size;
|
|
312
|
+
console.log(`\nColour vision: ${pairs} pair(s) of tones close together, in ${cvd.length} view(s) below ΔE ${MIN_DELTA_E} (a warning; give them an icon or a label as well):`);
|
|
226
313
|
console.log(describeCvdWarnings(cvd).join('\n'));
|
|
227
314
|
}
|
|
228
315
|
if (flags['dry-run']) {
|
|
@@ -233,6 +320,7 @@ export async function themeCommand({ positional, flags, root, sourcePath, config
|
|
|
233
320
|
console.log(`\nWrote ${sourcePath.slice(root.length + 1)}.`);
|
|
234
321
|
installFonts(root, config, theme, flags);
|
|
235
322
|
await build();
|
|
323
|
+
rebuildRegistry(root);
|
|
236
324
|
return;
|
|
237
325
|
}
|
|
238
326
|
|
package/lib/usage.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which components draw which tokens: the Studio's "Show usage" (apps/docs). Each kit's `registry:build` calls
|
|
3
|
+
* tokenUsage with its registry (component ids and their files) and writes the result to
|
|
4
|
+
* registry/generated/token-usage.json, which the docs site reads.
|
|
5
|
+
*
|
|
6
|
+
* tokenUsage({ root, components: [{ id, files }], tokens }) → { 'color.primary': ['button', …], 'radius.md': […], … }
|
|
7
|
+
*
|
|
8
|
+
* Keys:
|
|
9
|
+
* color.<name> a semantic colour (or an extra) in a class (bg-primary, text-muted-foreground/80, border-input),
|
|
10
|
+
* var(--name), or the mobile palette (palette.mutedForeground)
|
|
11
|
+
* radius.<step> rounded-sm…2xl, full, control (any side: rounded-t-md)
|
|
12
|
+
* shadow.<1|2|3> shadow-xs / shadow-sm / shadow (1), shadow-md (2), shadow-lg / xl / 2xl (3)
|
|
13
|
+
* type.<style> type-h2 (web), text-type-h2 (mobile), or a Text variant ("h2")
|
|
14
|
+
* font.<role> font-heading, font-sans (body), font-mono
|
|
15
|
+
* border.width border, border-x…, the theme's width
|
|
16
|
+
* component.<name> the component layer (button, chip, badge, input): its classes (bg-button-primary-bg,
|
|
17
|
+
* rounded-chip, type-badge) and variables (--field-*). A variant colour also counts as the role
|
|
18
|
+
* tokens.json points it at (bg-chip-selected-bg → color.foreground), so a role's usage still finds it
|
|
19
|
+
* A static read of class strings: a class built at runtime from parts is not seen.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { COMPONENT_NAMES, COMPONENTS, SLOT_VAR } from './theme/components.mjs';
|
|
24
|
+
|
|
25
|
+
const TEXT_STYLES = ['h1', 'h2', 'h3', 'h4', 'lead', 'p', 'large', 'small', 'muted', 'code', 'blockquote'];
|
|
26
|
+
const COLOR_UTILITY =
|
|
27
|
+
/^(?:bg|text|border(?:-[xytrblse])?|divide|outline|ring(?:-offset)?|fill|stroke|from|via|to|accent|caret|placeholder|decoration|shadow)-(.+?)(?:\/[\d.]+)?$/;
|
|
28
|
+
const RADIUS = /^rounded(?:-[trblse]{1,2})?-(sm|md|lg|xl|2xl|full|control)$/;
|
|
29
|
+
const SHADOW = { 'shadow-2xs': '1', 'shadow-xs': '1', 'shadow-sm': '1', shadow: '1', 'shadow-md': '2', 'shadow-lg': '3', 'shadow-xl': '3', 'shadow-2xl': '3' };
|
|
30
|
+
const FONT = { 'font-heading': 'heading', 'font-sans': 'body', 'font-mono': 'mono' };
|
|
31
|
+
|
|
32
|
+
const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
33
|
+
|
|
34
|
+
/** The class itself, without variants (`dark:hover:`, `[&_svg]:`), important (`!`) or a negative sign. */
|
|
35
|
+
function baseClass(token) {
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let cut = -1;
|
|
38
|
+
for (let i = 0; i < token.length; i++) {
|
|
39
|
+
if (token[i] === '[') depth++;
|
|
40
|
+
else if (token[i] === ']') depth--;
|
|
41
|
+
else if (token[i] === ':' && depth === 0) cut = i;
|
|
42
|
+
}
|
|
43
|
+
return token.slice(cut + 1).replace(/^!|!$/g, '').replace(/^-/, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The colour names a token file defines: semantic colours and extras (a ramp's steps as name-step). */
|
|
47
|
+
export function colourNames(tokens) {
|
|
48
|
+
const names = new Set(Object.keys(tokens.semantic?.color ?? {}));
|
|
49
|
+
for (const [name, node] of Object.entries(tokens.extra?.color ?? {})) {
|
|
50
|
+
if (node && '$value' in node) names.add(name);
|
|
51
|
+
else for (const step of Object.keys(node ?? {})) if (/^\d+$/.test(step)) names.add(`${name}-${step}`);
|
|
52
|
+
}
|
|
53
|
+
return names;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The prefix each component's classes and variables use (Input's are field-*). */
|
|
57
|
+
const PREFIX = Object.fromEntries(COMPONENT_NAMES.map((name) => [COMPONENTS[name].prefix, name]));
|
|
58
|
+
const COMPONENT_CLASS = new RegExp(`(?:^|[-(])(${Object.keys(PREFIX).join('|')})-`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The component layer's colour classes and the roles tokens.json points them at, both modes:
|
|
62
|
+
* { 'chip-selected-bg': ['foreground'], … }.
|
|
63
|
+
*/
|
|
64
|
+
export function componentColourRoles(tokens) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const name of COMPONENT_NAMES) {
|
|
67
|
+
const group = tokens.semantic?.component?.[name];
|
|
68
|
+
if (!group) continue;
|
|
69
|
+
for (const [variant, slots] of Object.entries(group)) {
|
|
70
|
+
if (!slots || typeof slots !== 'object' || '$value' in slots) continue;
|
|
71
|
+
for (const [slot, key] of Object.entries(SLOT_VAR)) {
|
|
72
|
+
const value = slots[slot]?.$value;
|
|
73
|
+
if (!value) continue;
|
|
74
|
+
const refs = typeof value === 'object' ? [value.light, value.dark] : [value];
|
|
75
|
+
const roles = [...new Set(refs.map((r) => /^\{semantic\.color\.([a-z0-9-]+)\}$/.exec(String(r))?.[1]).filter(Boolean))];
|
|
76
|
+
out[`${COMPONENTS[name].prefix}-${variant}-${key}`] = roles;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The token keys one source file uses. `roles`: componentColourRoles. */
|
|
84
|
+
export function usageIn(code, colours, roles = {}) {
|
|
85
|
+
const keys = new Set();
|
|
86
|
+
// Comments go first: an apostrophe in one ("the theme's") would pair with a quote in the code.
|
|
87
|
+
const source = code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
88
|
+
const byCamel = new Map([...colours].map((n) => [camel(n), n]));
|
|
89
|
+
for (const m of source.matchAll(/(["'`])((?:(?!\1)[^\\]|\\.)*)\1/g)) {
|
|
90
|
+
for (const token of m[2].split(/\s+/)) {
|
|
91
|
+
if (!token) continue;
|
|
92
|
+
const cls = baseClass(token);
|
|
93
|
+
const colour = COLOR_UTILITY.exec(cls);
|
|
94
|
+
if (colour && colours.has(colour[1])) keys.add(`color.${colour[1]}`);
|
|
95
|
+
if (colour && roles[colour[1]]) for (const role of roles[colour[1]]) keys.add(`color.${role}`);
|
|
96
|
+
const component = COMPONENT_CLASS.exec(cls);
|
|
97
|
+
if (component && (roles[colour?.[1]] || /^(rounded|h|w|size|px|type|text-type|leading-type|tracking-type|border(-b)?)-/.test(cls))) keys.add(`component.${PREFIX[component[1]]}`);
|
|
98
|
+
const radius = RADIUS.exec(cls);
|
|
99
|
+
if (radius) keys.add(`radius.${radius[1]}`);
|
|
100
|
+
if (SHADOW[cls]) keys.add(`shadow.${SHADOW[cls]}`);
|
|
101
|
+
const type = /^(?:text-)?type-([a-z0-9]+)$/.exec(cls);
|
|
102
|
+
if (type && TEXT_STYLES.includes(type[1])) keys.add(`type.${type[1]}`);
|
|
103
|
+
if (FONT[cls]) keys.add(`font.${FONT[cls]}`);
|
|
104
|
+
if (/^border(?:-[xytrblse])?$/.test(cls)) keys.add('border.width');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const m of source.matchAll(/var\(--([a-z0-9-]+)\)/g)) if (colours.has(m[1])) keys.add(`color.${m[1]}`);
|
|
108
|
+
for (const m of source.matchAll(/--(button|chip|badge|field)-[a-z0-9-]+/g)) if (!/^--(button|field)(-(foreground|edge))?$/.test(m[0])) keys.add(`component.${PREFIX[m[1]]}`);
|
|
109
|
+
for (const m of source.matchAll(/\b(?:palette|colors|colours)\.([a-z][A-Za-z0-9]*)\b/g)) if (byCamel.has(m[1])) keys.add(`color.${byCamel.get(m[1])}`);
|
|
110
|
+
for (const m of source.matchAll(/\bvariant(?:=|:\s*)\{?["']([a-z0-9]+)["']/g)) if (TEXT_STYLES.includes(m[1])) keys.add(`type.${m[1]}`);
|
|
111
|
+
return keys;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function tokenUsage({ root, components, tokens }) {
|
|
115
|
+
const colours = colourNames(tokens);
|
|
116
|
+
const roles = componentColourRoles(tokens);
|
|
117
|
+
const usage = {};
|
|
118
|
+
for (const { id, files } of components) {
|
|
119
|
+
const keys = new Set();
|
|
120
|
+
for (const file of files ?? []) {
|
|
121
|
+
let source;
|
|
122
|
+
try {
|
|
123
|
+
source = readFileSync(join(root, file), 'utf8');
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const key of usageIn(source, colours, roles)) keys.add(key);
|
|
128
|
+
}
|
|
129
|
+
for (const key of keys) (usage[key] ??= []).push(id);
|
|
130
|
+
}
|
|
131
|
+
return Object.fromEntries(
|
|
132
|
+
Object.keys(usage)
|
|
133
|
+
.sort()
|
|
134
|
+
.map((key) => [key, [...new Set(usage[key])].sort()])
|
|
135
|
+
);
|
|
136
|
+
}
|