@gusnips/vite 0.8.43 → 0.8.45
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 +185 -1
- package/dist/contrast.d.ts +60 -1
- package/dist/contrast.d.ts.map +1 -1
- package/dist/contrast.js +277 -9
- package/dist/contrast.js.map +1 -1
- package/dist/discovery.d.ts +76 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +37 -0
- package/dist/discovery.js.map +1 -0
- package/dist/head.d.ts +6 -6
- package/dist/head.d.ts.map +1 -1
- package/dist/head.js +11 -10
- package/dist/head.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/contrast-tokens.test.ts +256 -0
- package/src/contrast.ts +375 -8
- package/src/discovery.test.ts +120 -0
- package/src/discovery.ts +103 -0
- package/src/fixtures/contrast/tokens/ok-media.css +32 -0
- package/src/fixtures/contrast/tokens/ok.css +30 -0
- package/src/head.test.ts +41 -11
- package/src/head.ts +12 -10
- package/src/index.ts +13 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
checkTokenPairs,
|
|
8
|
+
formatContrastReport,
|
|
9
|
+
loadTokenThemes,
|
|
10
|
+
type ContrastProblem,
|
|
11
|
+
type TokenPairsBundle,
|
|
12
|
+
} from "./contrast.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `checkTokenPairs` and `loadTokenThemes`, the plain-CSS half of `contrast.ts`. Nothing is
|
|
16
|
+
* compiled, so these read real stylesheets and run in milliseconds, unlike the Tailwind half.
|
|
17
|
+
*
|
|
18
|
+
* Two committed fixtures keep the whole contract (`ok.css`, and `ok-media.css` with both dark
|
|
19
|
+
* blocks wrapped in `@media`) and are the positive controls. Every failing case is one of them
|
|
20
|
+
* with ONE line changed, written to a temp folder: the change sits beside its assertion, and
|
|
21
|
+
* `change` throws when the text it was told to replace is not there, so a fixture edited later
|
|
22
|
+
* cannot turn a test into one that passes for nothing.
|
|
23
|
+
*/
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const fixtures = join(here, "fixtures/contrast/tokens");
|
|
26
|
+
|
|
27
|
+
const bundle: TokenPairsBundle = {
|
|
28
|
+
name: "site",
|
|
29
|
+
css: "tokens.css",
|
|
30
|
+
dark: ':root[data-theme="dark"]',
|
|
31
|
+
system: ":root:not([data-theme])",
|
|
32
|
+
textPairs: [
|
|
33
|
+
["--ground", "--ink"],
|
|
34
|
+
["--ground", "--muted"],
|
|
35
|
+
["--accent", "--accent-ink"],
|
|
36
|
+
],
|
|
37
|
+
nonTextPairs: [
|
|
38
|
+
["--ground", "--line"],
|
|
39
|
+
["--ground", "--accent"],
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const folders: string[] = [];
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(
|
|
46
|
+
folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function fixture(name: string): Promise<string> {
|
|
51
|
+
return readFile(join(fixtures, name), "utf8");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replace text that must be there, every occurrence: the fallback repeats the dark colours. */
|
|
55
|
+
function change(css: string, from: string, to: string): string {
|
|
56
|
+
if (!css.includes(from))
|
|
57
|
+
throw new Error(`the fixture no longer contains ${JSON.stringify(from)}`);
|
|
58
|
+
return css.replaceAll(from, to);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function check(css: string, overrides: Partial<TokenPairsBundle> = {}) {
|
|
62
|
+
const root = await mkdtemp(join(tmpdir(), "kit-contrast-tokens-"));
|
|
63
|
+
folders.push(root);
|
|
64
|
+
await writeFile(join(root, "tokens.css"), css);
|
|
65
|
+
return checkTokenPairs([{ ...bundle, ...overrides }], { root });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lineOf = (css: string, text: string): number =>
|
|
69
|
+
css.split("\n").findIndex((line) => line.includes(text)) + 1;
|
|
70
|
+
|
|
71
|
+
const only = (problems: ContrastProblem[]): ContrastProblem => {
|
|
72
|
+
expect(problems).toHaveLength(1);
|
|
73
|
+
return problems[0]!;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("checkTokenPairs", () => {
|
|
77
|
+
it("passes a palette whose pairs clear their floors in both themes", async () => {
|
|
78
|
+
const report = await check(await fixture("ok.css"));
|
|
79
|
+
expect(report.problems).toEqual([]);
|
|
80
|
+
expect(formatContrastReport(report)).toBe("✔ contrast: no errors");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reads blocks wrapped in @media, at any depth", async () => {
|
|
84
|
+
const report = await check(await fixture("ok-media.css"));
|
|
85
|
+
expect(report.problems).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("compares the fallback as colours and selectors as selectors, not as text", async () => {
|
|
89
|
+
let css = await fixture("ok.css");
|
|
90
|
+
css = change(css, " --ink: #f2f2f2;", " --ink: #F2F2F2;");
|
|
91
|
+
css = change(css, ':root[data-theme="dark"]', ":root[data-theme='dark']");
|
|
92
|
+
expect((await check(css)).problems).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("fails a pair under its floor in the theme where it fails, and points at the line", async () => {
|
|
96
|
+
const css = change(await fixture("ok.css"), "--muted: #b3b3b3", "--muted: #3a3a3a");
|
|
97
|
+
const report = await check(css);
|
|
98
|
+
const found = only(report.problems);
|
|
99
|
+
expect(found).toMatchObject({ rule: "floor", level: "error", bundle: "site" });
|
|
100
|
+
expect(found.message).toBe(
|
|
101
|
+
"dark: --muted (#3a3a3a) on --ground (#121212) is 1.65:1, under the 4.5:1 floor for text (WCAG 1.4.3)",
|
|
102
|
+
);
|
|
103
|
+
expect(found.at).toBe(`tokens.css:${String(lineOf(css, "--muted: #3a3a3a"))}`);
|
|
104
|
+
// The formatter is checkContrast's own, so a repo prints and exits the same way for both.
|
|
105
|
+
expect(formatContrastReport(report)).toContain("✖ contrast: 1 error(s)");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("holds a pair that is not text to the lower floor", async () => {
|
|
109
|
+
const css = change(await fixture("ok.css"), "--line: #767676", "--line: #cccccc");
|
|
110
|
+
expect(only((await check(css)).problems).message).toBe(
|
|
111
|
+
"light: --line (#cccccc) on --ground (#ffffff) is 1.61:1, under the 3:1 floor for a control or icon (WCAG 1.4.11)",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("takes the floors from the bundle", async () => {
|
|
116
|
+
const css = change(await fixture("ok.css"), "--muted: #555555", "--muted: #999999");
|
|
117
|
+
expect(only((await check(css)).problems).message).toContain("2.85:1, under the 4.5:1 floor");
|
|
118
|
+
expect((await check(css, { textFloor: 2.5 })).problems).toEqual([]);
|
|
119
|
+
// Raising the non-text floor fails the untouched palette: --line is 4.54:1 in the light theme.
|
|
120
|
+
const strict = await check(await fixture("ok.css"), { nonTextFloor: 5 });
|
|
121
|
+
expect(only(strict.problems).message).toContain(
|
|
122
|
+
"light: --line (#767676) on --ground (#ffffff) is 4.54:1",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reports a colour one theme declares and the other does not, once", async () => {
|
|
127
|
+
// The dark block loses --line (and the fallback with it, so only one rule fires).
|
|
128
|
+
const noDark = change(await fixture("ok.css"), " --line: #8a8a8a;\n", "");
|
|
129
|
+
const dropped = only((await check(noDark)).problems);
|
|
130
|
+
expect(dropped.rule).toBe("missing");
|
|
131
|
+
expect(dropped.message).toContain("`--line` is in the light block but not in the dark block");
|
|
132
|
+
expect(dropped.at).toBe(`tokens.css:${String(lineOf(noDark, "--line: #767676"))}`);
|
|
133
|
+
|
|
134
|
+
const noLight = change(await fixture("ok.css"), " --accent-ink: #fff;\n", "");
|
|
135
|
+
const unbound = only((await check(noLight)).problems);
|
|
136
|
+
expect(unbound.rule).toBe("missing");
|
|
137
|
+
expect(unbound.message).toContain("`--accent-ink` is in the dark block but not in the light");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("reports a pair that names a token no block declares, once however many pairs use it", async () => {
|
|
141
|
+
const report = await check(await fixture("ok.css"), {
|
|
142
|
+
textPairs: [
|
|
143
|
+
["--ground", "--inkk"],
|
|
144
|
+
["--inkk", "--muted"],
|
|
145
|
+
["ground", "--ink"],
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
expect(report.problems.map((p) => p.rule)).toEqual(["missing", "missing"]);
|
|
149
|
+
expect(report.problems[0]?.message).toContain("`--inkk`, which neither the light nor");
|
|
150
|
+
expect(report.problems[1]?.message).toContain("`ground`");
|
|
151
|
+
expect(report.problems[1]?.message).toContain("start with --");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("fails a fallback that holds a different colour, naming both", async () => {
|
|
155
|
+
const css = change(await fixture("ok.css"), " --ink: #f2f2f2;", " --ink: #eeeeee;");
|
|
156
|
+
const found = only((await check(css)).problems);
|
|
157
|
+
expect(found.rule).toBe("mirror");
|
|
158
|
+
expect(found.message).toBe(
|
|
159
|
+
"`--ink` is #f2f2f2 in the dark block but #eeeeee in the system block",
|
|
160
|
+
);
|
|
161
|
+
expect(found.at).toBe(`tokens.css:${String(lineOf(css, "--ink: #eeeeee"))}`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("fails a fallback that lacks a token, or holds one the dark block lacks", async () => {
|
|
165
|
+
const lacks = change(await fixture("ok.css"), " --line: #8a8a8a;\n", "");
|
|
166
|
+
expect(only((await check(lacks)).problems).message).toBe(
|
|
167
|
+
"`--line` is in the dark block but not in the system block",
|
|
168
|
+
);
|
|
169
|
+
const extra = change(
|
|
170
|
+
await fixture("ok.css"),
|
|
171
|
+
" --accent: #7ab7ff;",
|
|
172
|
+
" --accent: #7ab7ff;\n --extra: #000000;",
|
|
173
|
+
);
|
|
174
|
+
expect(only((await check(extra)).problems).message).toBe(
|
|
175
|
+
"`--extra` is in the system block but not in the dark block",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("fails a fallback written outside @media (prefers-color-scheme: dark)", async () => {
|
|
180
|
+
const css = change(
|
|
181
|
+
await fixture("ok.css"),
|
|
182
|
+
"@media (prefers-color-scheme: dark)",
|
|
183
|
+
"@media print",
|
|
184
|
+
);
|
|
185
|
+
const found = only((await check(css)).problems);
|
|
186
|
+
expect(found.rule).toBe("mirror");
|
|
187
|
+
expect(found.message).toContain("is not inside @media (prefers-color-scheme: dark)");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("fails a fallback that is not there, and reads none when the bundle names none", async () => {
|
|
191
|
+
const css = await fixture("ok.css");
|
|
192
|
+
const absent = only((await check(css, { system: ":root:not([data-mode])" })).problems);
|
|
193
|
+
expect(absent.rule).toBe("mirror");
|
|
194
|
+
expect(absent.message).toContain("no `:root:not([data-mode])` block");
|
|
195
|
+
|
|
196
|
+
const differs = change(css, " --ink: #f2f2f2;", " --ink: #eeeeee;");
|
|
197
|
+
expect((await check(differs, { system: undefined })).problems).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("fails a pair it cannot measure, and leaves tokens no pair uses alone", async () => {
|
|
201
|
+
// ok.css already carries `--radius: 8px` in the light block and passes: geometry is skipped.
|
|
202
|
+
const css = change(await fixture("ok.css"), "--muted: #555555", "--muted: rgb(85 85 85)");
|
|
203
|
+
const found = only((await check(css)).problems);
|
|
204
|
+
expect(found.rule).toBe("floor");
|
|
205
|
+
expect(found.message).toContain("cannot measure --muted (rgb(85 85 85))");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("a stylesheet or block that is not there fails the gate instead of passing it", async () => {
|
|
209
|
+
const css = await fixture("ok.css");
|
|
210
|
+
const noFile = await checkTokenPairs([bundle], { root: fixtures });
|
|
211
|
+
expect(only(noFile.problems)).toMatchObject({
|
|
212
|
+
rule: "load",
|
|
213
|
+
message: "tokens.css could not be read",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const noDark = only((await check(css, { dark: '[data-theme="night"]' })).problems);
|
|
217
|
+
expect(noDark.rule).toBe("load");
|
|
218
|
+
expect(noDark.message).toContain('no `[data-theme="night"]` block for the dark colours');
|
|
219
|
+
|
|
220
|
+
const same = only((await check(css, { light: bundle.dark })).problems);
|
|
221
|
+
expect(same.rule).toBe("load");
|
|
222
|
+
expect(same.message).toContain("must differ");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("does not read a block that holds another block", async () => {
|
|
226
|
+
const css = change(
|
|
227
|
+
await fixture("ok.css"),
|
|
228
|
+
" --accent-ink: #0a1a2b;\n}\n\n@media",
|
|
229
|
+
" --accent-ink: #0a1a2b;\n & a {\n color: red;\n }\n}\n\n@media",
|
|
230
|
+
);
|
|
231
|
+
expect(only((await check(css)).problems).rule).toBe("load");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("loadTokenThemes", () => {
|
|
236
|
+
it("hands back both palettes as hex, so an adopter can add a check of its own", async () => {
|
|
237
|
+
const { light, dark } = await loadTokenThemes(
|
|
238
|
+
{ css: "ok.css", dark: bundle.dark },
|
|
239
|
+
{ root: fixtures },
|
|
240
|
+
);
|
|
241
|
+
expect(light.get("--ground")).toBe("#ffffff");
|
|
242
|
+
expect(dark.get("--ground")).toBe("#121212");
|
|
243
|
+
// `#fff` comes back as `#ffffff`, and `--radius: 8px` is geometry, not a colour.
|
|
244
|
+
expect(light.get("--accent-ink")).toBe("#ffffff");
|
|
245
|
+
expect(light.has("--radius")).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("throws, saying what is missing, rather than returning two empty palettes", async () => {
|
|
249
|
+
await expect(
|
|
250
|
+
loadTokenThemes({ css: "ok.css", dark: '[data-theme="night"]' }, { root: fixtures }),
|
|
251
|
+
).rejects.toThrow('no `[data-theme="night"]` block');
|
|
252
|
+
await expect(
|
|
253
|
+
loadTokenThemes({ css: "nope.css", dark: bundle.dark }, { root: fixtures }),
|
|
254
|
+
).rejects.toThrow("nope.css could not be read");
|
|
255
|
+
});
|
|
256
|
+
});
|
package/src/contrast.ts
CHANGED
|
@@ -30,9 +30,20 @@
|
|
|
30
30
|
* - **dead**: a class that compiles to no rule at all — a token Tailwind does not have, or a
|
|
31
31
|
* name the scanner caught half of. A warning: dynamic construction reads the same way.
|
|
32
32
|
*
|
|
33
|
+
* `checkContrast` is the Tailwind reading. `checkTokenPairs` (further down) is the other one, for
|
|
34
|
+
* an app whose colours are plain CSS custom properties: it reads a light block and a dark block as
|
|
35
|
+
* text, holds a list of pairs to a floor in both, and reports through the same problems and the
|
|
36
|
+
* same formatter. It adds two rules:
|
|
37
|
+
*
|
|
38
|
+
* - **missing**: a colour one theme declares and the other does not, or a pair that names a token
|
|
39
|
+
* neither declares. A typo in a pair would otherwise read as a pair that passes.
|
|
40
|
+
* - **mirror**: the system fallback (the dark colours repeated under `prefers-color-scheme` for a
|
|
41
|
+
* page that has chosen no theme) differs from the dark block, or sits outside that query.
|
|
42
|
+
*
|
|
33
43
|
* Runs on node and bun: `fs.promises.glob`, never Bun's `Glob`, and no `import.meta.dir`.
|
|
34
|
-
*
|
|
35
|
-
* `@gusnips/vite/contrast`), because the fixture is compiled, not guessed.
|
|
44
|
+
* `checkContrast` needs `@tailwindcss/cli` installed where it runs (an optional peer, reachable
|
|
45
|
+
* only from `@gusnips/vite/contrast`), because the fixture is compiled, not guessed.
|
|
46
|
+
* `checkTokenPairs` compiles nothing and needs no peer.
|
|
36
47
|
*/
|
|
37
48
|
import { spawnSync } from "node:child_process";
|
|
38
49
|
import { glob, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -56,7 +67,8 @@ export interface CheckContrastOptions {
|
|
|
56
67
|
root?: string;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
export type ContrastRule =
|
|
70
|
+
export type ContrastRule =
|
|
71
|
+
"load" | "floor" | "retune" | "rebind" | "untokened" | "hover" | "dead" | "missing" | "mirror";
|
|
60
72
|
|
|
61
73
|
export interface ContrastProblem {
|
|
62
74
|
rule: ContrastRule;
|
|
@@ -149,6 +161,13 @@ const ATTR = /\bclass(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|`((?:[^`$]|\$(?!\{))
|
|
|
149
161
|
const HELPER = /\b(?:cn|clsx|cva|twMerge|twJoin)\s*\(([^)]*)\)/g;
|
|
150
162
|
const STR = /"([^"]*)"|'([^']*)'/g;
|
|
151
163
|
|
|
164
|
+
/** The 1-based line a character offset falls on. */
|
|
165
|
+
function lineAt(source: string, index: number): number {
|
|
166
|
+
let line = 1;
|
|
167
|
+
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
168
|
+
return line;
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
const skipped = (file: string): boolean =>
|
|
153
172
|
/(?:^|\/)__tests__\//.test(file) || /\.(test|spec)\.[tj]sx?$/.test(file);
|
|
154
173
|
|
|
@@ -164,11 +183,6 @@ async function scanSources(
|
|
|
164
183
|
if (!skipped(file)) files.add(file);
|
|
165
184
|
}
|
|
166
185
|
}
|
|
167
|
-
const lineAt = (source: string, index: number): number => {
|
|
168
|
-
let line = 1;
|
|
169
|
-
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
170
|
-
return line;
|
|
171
|
-
};
|
|
172
186
|
const keep = (raw: string, file: string, index: number, source: string): void => {
|
|
173
187
|
for (const cls of raw.split(/\s+/).filter(Boolean)) {
|
|
174
188
|
// A token that would break the fixture's quoting is skipped rather than escaped:
|
|
@@ -506,3 +520,356 @@ export function formatContrastReport(report: ContrastReport): string {
|
|
|
506
520
|
);
|
|
507
521
|
return lines.join("\n");
|
|
508
522
|
}
|
|
523
|
+
|
|
524
|
+
// ── Plain CSS tokens ──────────────────────────────────────────────────────────
|
|
525
|
+
// The same gate for an app with no Tailwind `@theme`: its colours are custom properties in a
|
|
526
|
+
// light block and a dark block, and a list of pairs that must stay readable in both. The file is
|
|
527
|
+
// read as text and nothing is compiled, so this reading installs no optional peer.
|
|
528
|
+
//
|
|
529
|
+
// The limits are stated rather than hidden: hex colours only (a pair that names anything else
|
|
530
|
+
// fails as unmeasurable, never passes), blocks found by their selector text, and a block that
|
|
531
|
+
// holds another block (nested CSS) is not read. There is no CSS parser here. If themes outgrow
|
|
532
|
+
// flat blocks, replace `leafRules` with one.
|
|
533
|
+
|
|
534
|
+
/** Two custom properties, `[background, foreground]`, written as the CSS names them:
|
|
535
|
+
* `["--ground", "--ink"]`. The ratio is the same either way round; the order only decides how a
|
|
536
|
+
* problem reads. */
|
|
537
|
+
export type TokenPair = readonly [background: string, foreground: string];
|
|
538
|
+
|
|
539
|
+
export interface TokenPairsBundle {
|
|
540
|
+
/** Shown beside every problem: `"apps/site"`. */
|
|
541
|
+
name: string;
|
|
542
|
+
/** The stylesheet holding the colour blocks, relative to `root`. */
|
|
543
|
+
css: string;
|
|
544
|
+
/** The selector of the light block. Default `":root"`. It must differ from `dark`. */
|
|
545
|
+
light?: string;
|
|
546
|
+
/** The selector of the dark block: `':root[data-theme="dark"]'`. */
|
|
547
|
+
dark: string;
|
|
548
|
+
/**
|
|
549
|
+
* The selector of the system fallback: the dark colours again, for a page that has chosen no
|
|
550
|
+
* theme, inside `@media (prefers-color-scheme: dark)`: `":root:not([data-theme])"`. It must
|
|
551
|
+
* hold exactly the dark block's tokens and values. Leave it off when there is none.
|
|
552
|
+
*/
|
|
553
|
+
system?: string;
|
|
554
|
+
/** Pairs read as text. Held to `textFloor`. */
|
|
555
|
+
textPairs?: readonly TokenPair[];
|
|
556
|
+
/** Pairs that are not text: borders, focus rings, icons. Held to `nonTextFloor`. */
|
|
557
|
+
nonTextPairs?: readonly TokenPair[];
|
|
558
|
+
/** Default 4.5 (WCAG 1.4.3). */
|
|
559
|
+
textFloor?: number;
|
|
560
|
+
/** Default 3 (WCAG 1.4.11). */
|
|
561
|
+
nonTextFloor?: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The hex colours of each theme, `--ground` → `#ffffff`, for a check of your own. */
|
|
565
|
+
export interface TokenThemes {
|
|
566
|
+
light: Map<string, string>;
|
|
567
|
+
dark: Map<string, string>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interface TokenDecl {
|
|
571
|
+
value: string;
|
|
572
|
+
line: number;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
interface CssRule {
|
|
576
|
+
selectors: string[];
|
|
577
|
+
/** The at-rules around it, outermost first: `@media screen @media (prefers-color-scheme: dark)`. */
|
|
578
|
+
within: string;
|
|
579
|
+
tokens: Map<string, TokenDecl>;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
interface TokenBlock {
|
|
583
|
+
tokens: Map<string, TokenDecl>;
|
|
584
|
+
/** `within` of every block that matched, since one selector may be written twice. */
|
|
585
|
+
within: string[];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
interface TokenBlocks {
|
|
589
|
+
light: TokenBlock;
|
|
590
|
+
dark: TokenBlock;
|
|
591
|
+
/** `null` when the bundle names no system block, or names one that is not in the file. */
|
|
592
|
+
system: TokenBlock | null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const PREFERS_DARK = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/;
|
|
596
|
+
|
|
597
|
+
/** `:root[data-theme='dark']` and `:root[data-theme="dark"]` are the same selector. */
|
|
598
|
+
const selectorOf = (text: string): string => text.replace(/\s+/g, " ").replace(/'/g, '"').trim();
|
|
599
|
+
|
|
600
|
+
/** The comparable form of a value: `#FFF` and `#ffffff` are one colour. */
|
|
601
|
+
const canonical = (value: string): string =>
|
|
602
|
+
normalizeHex(value) ?? value.replace(/\s+/g, " ").toLowerCase();
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Every innermost `selector { … }` block with the at-rules around it, and the custom properties
|
|
606
|
+
* it declares. Text, not syntax: a brace or semicolon inside a string or `url()` is not understood.
|
|
607
|
+
*/
|
|
608
|
+
function leafRules(css: string): CssRule[] {
|
|
609
|
+
// Comments become spaces, so a `{` in one is not a block and every offset still names its line.
|
|
610
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
|
|
611
|
+
const rules: CssRule[] = [];
|
|
612
|
+
const open: Array<{ prelude: string; bodyAt: number; nested: boolean }> = [];
|
|
613
|
+
let preludeFrom = 0;
|
|
614
|
+
for (let i = 0; i < source.length; i++) {
|
|
615
|
+
const char = source[i];
|
|
616
|
+
if (char === "{") {
|
|
617
|
+
const parent = open.at(-1);
|
|
618
|
+
if (parent !== undefined) parent.nested = true;
|
|
619
|
+
open.push({ prelude: source.slice(preludeFrom, i).trim(), bodyAt: i + 1, nested: false });
|
|
620
|
+
preludeFrom = i + 1;
|
|
621
|
+
} else if (char === "}") {
|
|
622
|
+
const block = open.pop();
|
|
623
|
+
preludeFrom = i + 1;
|
|
624
|
+
if (block === undefined || block.nested || block.prelude.startsWith("@")) continue;
|
|
625
|
+
const tokens = new Map<string, TokenDecl>();
|
|
626
|
+
for (const match of source.slice(block.bodyAt, i).matchAll(/(--[\w-]+)\s*:\s*([^;]*)/g)) {
|
|
627
|
+
tokens.set(match[1] ?? "", {
|
|
628
|
+
value: (match[2] ?? "").trim(),
|
|
629
|
+
line: lineAt(source, block.bodyAt + match.index),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
rules.push({
|
|
633
|
+
selectors: block.prelude.split(",").map(selectorOf),
|
|
634
|
+
within: open
|
|
635
|
+
.map((parent) => parent.prelude)
|
|
636
|
+
.filter((prelude) => prelude.startsWith("@"))
|
|
637
|
+
.join(" "),
|
|
638
|
+
tokens,
|
|
639
|
+
});
|
|
640
|
+
} else if (char === ";") {
|
|
641
|
+
preludeFrom = i + 1;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return rules;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Every block written with this selector, merged: a later token wins, as it does in CSS. */
|
|
648
|
+
function blockOf(rules: readonly CssRule[], selector: string): TokenBlock | null {
|
|
649
|
+
const wanted = selectorOf(selector);
|
|
650
|
+
const found = rules.filter((rule) => rule.selectors.includes(wanted));
|
|
651
|
+
if (found.length === 0) return null;
|
|
652
|
+
const tokens = new Map<string, TokenDecl>();
|
|
653
|
+
for (const rule of found) for (const [name, decl] of rule.tokens) tokens.set(name, decl);
|
|
654
|
+
return { tokens, within: found.map((rule) => rule.within) };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Throws when the file or a block the bundle names is not there. The system block may be absent:
|
|
658
|
+
* whether that is a problem is the check's question, not the loader's. */
|
|
659
|
+
async function readTokenBlocks(
|
|
660
|
+
bundle: Pick<TokenPairsBundle, "css" | "light" | "dark" | "system">,
|
|
661
|
+
root: string,
|
|
662
|
+
): Promise<TokenBlocks> {
|
|
663
|
+
const css = await readFile(resolve(root, bundle.css), "utf8").catch(() => null);
|
|
664
|
+
// A stylesheet that does not load reads as one with no tokens, which would pass every rule
|
|
665
|
+
// below. It fails instead, the same reason `checkContrast` has a load rule.
|
|
666
|
+
if (css === null) throw new Error(`${bundle.css} could not be read`);
|
|
667
|
+
const rules = leafRules(css);
|
|
668
|
+
const required = (label: string, selector: string): TokenBlock => {
|
|
669
|
+
const block = blockOf(rules, selector);
|
|
670
|
+
if (block === null) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`${bundle.css} has no \`${selector}\` block for the ${label} colours. A block is found by ` +
|
|
673
|
+
`its selector text, inside any @media, and holds declarations only`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
return block;
|
|
677
|
+
};
|
|
678
|
+
const lightSelector = bundle.light ?? ":root";
|
|
679
|
+
if (selectorOf(lightSelector) === selectorOf(bundle.dark)) {
|
|
680
|
+
throw new Error(`the light and dark selectors are both \`${lightSelector}\`; they must differ`);
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
light: required("light", lightSelector),
|
|
684
|
+
dark: required("dark", bundle.dark),
|
|
685
|
+
system: bundle.system === undefined ? null : blockOf(rules, bundle.system),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The tokens whose value is a hex colour, as `#rrggbb`. Type and geometry are left out. */
|
|
690
|
+
function hexTokens(tokens: ReadonlyMap<string, TokenDecl>): Map<string, string> {
|
|
691
|
+
const hex = new Map<string, string>();
|
|
692
|
+
for (const [name, { value }] of tokens) {
|
|
693
|
+
const colour = normalizeHex(value);
|
|
694
|
+
if (colour !== null) hex.set(name, colour);
|
|
695
|
+
}
|
|
696
|
+
return hex;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The two palettes of a bundle as hex maps, for a check of your own that needs the colours
|
|
701
|
+
* (the browser's toolbar colour should be the page's ground, in each theme):
|
|
702
|
+
*
|
|
703
|
+
* ```ts
|
|
704
|
+
* const { light, dark } = await loadTokenThemes({ css: "src/tokens.css", dark: '[data-theme="dark"]' });
|
|
705
|
+
* light.get("--ground"); // "#ffffff"
|
|
706
|
+
* ```
|
|
707
|
+
*
|
|
708
|
+
* Throws, saying what is missing, when the file or one of the two blocks is not there.
|
|
709
|
+
*/
|
|
710
|
+
export async function loadTokenThemes(
|
|
711
|
+
bundle: Pick<TokenPairsBundle, "css" | "light" | "dark">,
|
|
712
|
+
options: CheckContrastOptions = {},
|
|
713
|
+
): Promise<TokenThemes> {
|
|
714
|
+
const { light, dark } = await readTokenBlocks(bundle, options.root ?? process.cwd());
|
|
715
|
+
return { light: hexTokens(light.tokens), dark: hexTokens(dark.tokens) };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function checkTokenBundle(
|
|
719
|
+
bundle: TokenPairsBundle,
|
|
720
|
+
options: CheckContrastOptions,
|
|
721
|
+
report: ContrastReport,
|
|
722
|
+
): Promise<void> {
|
|
723
|
+
const problem = (p: Omit<ContrastProblem, "bundle" | "level">): void => {
|
|
724
|
+
report.problems.push({ bundle: bundle.name, level: "error", ...p });
|
|
725
|
+
};
|
|
726
|
+
const at = (decl: TokenDecl | undefined): string =>
|
|
727
|
+
decl === undefined ? bundle.css : `${bundle.css}:${String(decl.line)}`;
|
|
728
|
+
|
|
729
|
+
let blocks: TokenBlocks;
|
|
730
|
+
try {
|
|
731
|
+
blocks = await readTokenBlocks(bundle, options.root ?? process.cwd());
|
|
732
|
+
} catch (error) {
|
|
733
|
+
problem({ rule: "load", message: error instanceof Error ? error.message : String(error) });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const { light, dark, system } = blocks;
|
|
737
|
+
const themes: Array<[mode: string, tokens: Map<string, TokenDecl>]> = [
|
|
738
|
+
["light", light.tokens],
|
|
739
|
+
["dark", dark.tokens],
|
|
740
|
+
];
|
|
741
|
+
const textPairs = bundle.textPairs ?? [];
|
|
742
|
+
const nonTextPairs = bundle.nonTextPairs ?? [];
|
|
743
|
+
|
|
744
|
+
// ── Every colour has both themes, and every pair names a real token ──
|
|
745
|
+
const wanted = new Set([
|
|
746
|
+
...hexTokens(light.tokens).keys(),
|
|
747
|
+
...hexTokens(dark.tokens).keys(),
|
|
748
|
+
...[...textPairs, ...nonTextPairs].flat(),
|
|
749
|
+
]);
|
|
750
|
+
for (const token of wanted) {
|
|
751
|
+
const inLight = light.tokens.get(token);
|
|
752
|
+
const inDark = dark.tokens.get(token);
|
|
753
|
+
if (inLight !== undefined && inDark !== undefined) continue;
|
|
754
|
+
if (inLight === undefined && inDark === undefined) {
|
|
755
|
+
problem({
|
|
756
|
+
rule: "missing",
|
|
757
|
+
at: bundle.css,
|
|
758
|
+
message:
|
|
759
|
+
`a pair names \`${token}\`, which neither the light nor the dark block declares` +
|
|
760
|
+
(token.startsWith("--") ? "" : ". Custom property names start with --"),
|
|
761
|
+
});
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const [have, lack] = inLight === undefined ? ["dark", "light"] : ["light", "dark"];
|
|
765
|
+
problem({
|
|
766
|
+
rule: "missing",
|
|
767
|
+
at: at(inLight ?? inDark),
|
|
768
|
+
message:
|
|
769
|
+
`\`${token}\` is in the ${have} block but not in the ${lack} block. Declare it in both, ` +
|
|
770
|
+
`even when the colour is the same, so each theme says what it means`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── The pairs, on the values that ship in each theme ──
|
|
775
|
+
// A token missing from a theme was reported above, so its pairs are skipped here, not repeated.
|
|
776
|
+
const unmeasurable = new Set<string>();
|
|
777
|
+
const cannotMeasure = (mode: string, token: string, decl: TokenDecl): void => {
|
|
778
|
+
if (unmeasurable.has(`${mode}:${token}`)) return;
|
|
779
|
+
unmeasurable.add(`${mode}:${token}`);
|
|
780
|
+
problem({
|
|
781
|
+
rule: "floor",
|
|
782
|
+
at: at(decl),
|
|
783
|
+
message:
|
|
784
|
+
`${mode}: cannot measure ${token} (${decl.value}), because it is not a hex colour. ` +
|
|
785
|
+
`Keep the values hex, or teach this check the new notation; ` +
|
|
786
|
+
`do not let a pair go unmeasured.`,
|
|
787
|
+
});
|
|
788
|
+
};
|
|
789
|
+
const measure = (pairs: readonly TokenPair[], floor: number, why: string): void => {
|
|
790
|
+
for (const [background, foreground] of pairs) {
|
|
791
|
+
for (const [mode, tokens] of themes) {
|
|
792
|
+
const bg = tokens.get(background);
|
|
793
|
+
const fg = tokens.get(foreground);
|
|
794
|
+
if (bg === undefined || fg === undefined) continue;
|
|
795
|
+
const bgHex = normalizeHex(bg.value);
|
|
796
|
+
const fgHex = normalizeHex(fg.value);
|
|
797
|
+
if (bgHex === null || fgHex === null) {
|
|
798
|
+
if (bgHex === null) cannotMeasure(mode, background, bg);
|
|
799
|
+
if (fgHex === null) cannotMeasure(mode, foreground, fg);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const ratio = contrast(fgHex, bgHex);
|
|
803
|
+
if (ratio < floor) {
|
|
804
|
+
problem({
|
|
805
|
+
rule: "floor",
|
|
806
|
+
at: at(fg),
|
|
807
|
+
message:
|
|
808
|
+
`${mode}: ${foreground} (${fgHex}) on ${background} (${bgHex}) is ` +
|
|
809
|
+
`${ratio.toFixed(2)}:1, under the ${floor}:1 floor for ${why}`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
measure(textPairs, bundle.textFloor ?? 4.5, "text (WCAG 1.4.3)");
|
|
816
|
+
measure(nonTextPairs, bundle.nonTextFloor ?? 3, "a control or icon (WCAG 1.4.11)");
|
|
817
|
+
|
|
818
|
+
// ── The system fallback says what the dark block says ──
|
|
819
|
+
if (bundle.system === undefined) return;
|
|
820
|
+
const mirror = (decl: TokenDecl | undefined, message: string): void =>
|
|
821
|
+
problem({ rule: "mirror", at: at(decl), message });
|
|
822
|
+
if (system === null) {
|
|
823
|
+
mirror(
|
|
824
|
+
undefined,
|
|
825
|
+
`no \`${bundle.system}\` block for the system fallback. Without it a page that has ` +
|
|
826
|
+
`chosen no theme stays light on a dark system`,
|
|
827
|
+
);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!system.within.every((within) => PREFERS_DARK.test(within))) {
|
|
831
|
+
mirror(
|
|
832
|
+
[...system.tokens.values()][0],
|
|
833
|
+
`\`${bundle.system}\` is not inside @media (prefers-color-scheme: dark), so it paints ` +
|
|
834
|
+
`the dark colours for everyone who has chosen no theme`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
for (const token of new Set([...dark.tokens.keys(), ...system.tokens.keys()])) {
|
|
838
|
+
const chosen = dark.tokens.get(token);
|
|
839
|
+
const followed = system.tokens.get(token);
|
|
840
|
+
if (chosen !== undefined && followed !== undefined) {
|
|
841
|
+
if (canonical(chosen.value) !== canonical(followed.value)) {
|
|
842
|
+
mirror(
|
|
843
|
+
followed,
|
|
844
|
+
`\`${token}\` is ${chosen.value} in the dark block but ${followed.value} in the system block`,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
} else if (chosen !== undefined) {
|
|
848
|
+
mirror(chosen, `\`${token}\` is in the dark block but not in the system block`);
|
|
849
|
+
} else {
|
|
850
|
+
mirror(followed, `\`${token}\` is in the system block but not in the dark block`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The plain-CSS gate: for each stylesheet, the contrast of every pair in both themes, a token
|
|
857
|
+
* both themes have, and a system fallback that matches the dark block. Returns what it found,
|
|
858
|
+
* the same report `checkContrast` returns, so `formatContrastReport` prints either and a repo
|
|
859
|
+
* can join the two (`report.problems.push(...other.problems)`) and fail CI once.
|
|
860
|
+
*
|
|
861
|
+
* ```ts
|
|
862
|
+
* const report = await checkTokenPairs([
|
|
863
|
+
* { name: "site", css: "src/tokens.css", dark: ':root[data-theme="dark"]', textPairs: [["--ground", "--ink"]] },
|
|
864
|
+
* ]);
|
|
865
|
+
* console.log(formatContrastReport(report));
|
|
866
|
+
* ```
|
|
867
|
+
*/
|
|
868
|
+
export async function checkTokenPairs(
|
|
869
|
+
bundles: readonly TokenPairsBundle[],
|
|
870
|
+
options: CheckContrastOptions = {},
|
|
871
|
+
): Promise<ContrastReport> {
|
|
872
|
+
const report: ContrastReport = { problems: [] };
|
|
873
|
+
for (const bundle of bundles) await checkTokenBundle(bundle, options, report);
|
|
874
|
+
return report;
|
|
875
|
+
}
|