@gusnips/vite 0.8.44 → 0.8.46
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 +105 -1
- package/dist/contrast.d.ts +66 -1
- package/dist/contrast.d.ts.map +1 -1
- package/dist/contrast.js +297 -9
- package/dist/contrast.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/contrast-tokens.test.ts +293 -0
- package/src/contrast.ts +401 -8
- package/src/fixtures/contrast/tokens/ok-media.css +32 -0
- package/src/fixtures/contrast/tokens/ok.css +30 -0
- package/src/index.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
14
|
* Tailwind plugins), `renderTree` at `@gusnips/vite/render`, the theme pre-paint plugin at
|
|
15
15
|
* `@gusnips/vite/theme` (both React itself), the catalog check at `@gusnips/vite/i18n`, and the
|
|
16
|
-
* contrast gate at `@gusnips/vite/contrast` (tailwindcss). The last two run in CI and never in a
|
|
16
|
+
* contrast gate at `@gusnips/vite/contrast` (tailwindcss, for the class reading only). The last two run in CI and never in a
|
|
17
17
|
* build. Nothing here imports React, vite or tailwindcss, so a prerender script, an OG generator
|
|
18
18
|
* and a repo that only wants a sitemap all install exactly what they use.
|
|
19
19
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/vite",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.46",
|
|
4
4
|
"description": "The build-time half of a prerendered Vite SPA: head baking, sitemap, robots, the prerender driver, a shared vite preset, and the translation catalog check.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"release:major": "bun pm version major && bun publish --access public"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@gusnips/react": "0.9.
|
|
76
|
+
"@gusnips/react": "0.9.33"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"@tailwindcss/cli": "^4",
|
|
@@ -0,0 +1,293 @@
|
|
|
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 DARK = ':root[data-theme="dark"]';
|
|
28
|
+
const bundle: TokenPairsBundle = {
|
|
29
|
+
name: "site",
|
|
30
|
+
css: "tokens.css",
|
|
31
|
+
dark: DARK,
|
|
32
|
+
system: ":root:not([data-theme])",
|
|
33
|
+
textPairs: [
|
|
34
|
+
["--ground", "--ink"],
|
|
35
|
+
["--ground", "--muted"],
|
|
36
|
+
["--accent", "--accent-ink"],
|
|
37
|
+
],
|
|
38
|
+
nonTextPairs: [
|
|
39
|
+
["--ground", "--line"],
|
|
40
|
+
["--ground", "--accent"],
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const folders: string[] = [];
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await Promise.all(
|
|
47
|
+
folders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function fixture(name: string): Promise<string> {
|
|
52
|
+
return readFile(join(fixtures, name), "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Replace text that must be there, every occurrence: the fallback repeats the dark colours. */
|
|
56
|
+
function change(css: string, from: string, to: string): string {
|
|
57
|
+
if (!css.includes(from))
|
|
58
|
+
throw new Error(`the fixture no longer contains ${JSON.stringify(from)}`);
|
|
59
|
+
return css.replaceAll(from, to);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function check(css: string, overrides: Partial<TokenPairsBundle> = {}) {
|
|
63
|
+
const root = await mkdtemp(join(tmpdir(), "kit-contrast-tokens-"));
|
|
64
|
+
folders.push(root);
|
|
65
|
+
await writeFile(join(root, "tokens.css"), css);
|
|
66
|
+
return checkTokenPairs([{ ...bundle, ...overrides }], { root });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lineOf = (css: string, text: string): number =>
|
|
70
|
+
css.split("\n").findIndex((line) => line.includes(text)) + 1;
|
|
71
|
+
|
|
72
|
+
const only = (problems: ContrastProblem[]): ContrastProblem => {
|
|
73
|
+
expect(problems).toHaveLength(1);
|
|
74
|
+
return problems[0]!;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("checkTokenPairs", () => {
|
|
78
|
+
it("passes a palette whose pairs clear their floors in both themes", async () => {
|
|
79
|
+
const report = await check(await fixture("ok.css"));
|
|
80
|
+
expect(report.problems).toEqual([]);
|
|
81
|
+
expect(formatContrastReport(report)).toBe("✔ contrast: no errors");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads blocks wrapped in @media, at any depth", async () => {
|
|
85
|
+
const report = await check(await fixture("ok-media.css"));
|
|
86
|
+
expect(report.problems).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("compares the fallback as colours and selectors as selectors, not as text", async () => {
|
|
90
|
+
let css = await fixture("ok.css");
|
|
91
|
+
css = change(css, " --ink: #f2f2f2;", " --ink: #F2F2F2;");
|
|
92
|
+
css = change(css, ':root[data-theme="dark"]', ":root[data-theme='dark']");
|
|
93
|
+
expect((await check(css)).problems).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fails a pair under its floor in the theme where it fails, and points at the line", async () => {
|
|
97
|
+
const css = change(await fixture("ok.css"), "--muted: #b3b3b3", "--muted: #3a3a3a");
|
|
98
|
+
const report = await check(css);
|
|
99
|
+
const found = only(report.problems);
|
|
100
|
+
expect(found).toMatchObject({ rule: "floor", level: "error", bundle: "site" });
|
|
101
|
+
expect(found.message).toBe(
|
|
102
|
+
"dark: --muted (#3a3a3a) on --ground (#121212) is 1.65:1, under the 4.5:1 floor for text (WCAG 1.4.3)",
|
|
103
|
+
);
|
|
104
|
+
expect(found.at).toBe(`tokens.css:${String(lineOf(css, "--muted: #3a3a3a"))}`);
|
|
105
|
+
// The formatter is checkContrast's own, so a repo prints and exits the same way for both.
|
|
106
|
+
expect(formatContrastReport(report)).toContain("✖ contrast: 1 error(s)");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("holds a pair that is not text to the lower floor", async () => {
|
|
110
|
+
const css = change(await fixture("ok.css"), "--line: #767676", "--line: #cccccc");
|
|
111
|
+
expect(only((await check(css)).problems).message).toBe(
|
|
112
|
+
"light: --line (#cccccc) on --ground (#ffffff) is 1.61:1, under the 3:1 floor for a control or icon (WCAG 1.4.11)",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("takes the floors from the bundle", async () => {
|
|
117
|
+
const css = change(await fixture("ok.css"), "--muted: #555555", "--muted: #999999");
|
|
118
|
+
expect(only((await check(css)).problems).message).toContain("2.85:1, under the 4.5:1 floor");
|
|
119
|
+
expect((await check(css, { textFloor: 2.5 })).problems).toEqual([]);
|
|
120
|
+
// Raising the non-text floor fails the untouched palette: --line is 4.54:1 in the light theme.
|
|
121
|
+
const strict = await check(await fixture("ok.css"), { nonTextFloor: 5 });
|
|
122
|
+
expect(only(strict.problems).message).toContain(
|
|
123
|
+
"light: --line (#767676) on --ground (#ffffff) is 4.54:1",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reports a colour one theme declares and the other does not, once", async () => {
|
|
128
|
+
// The dark block loses --line (and the fallback with it, so only one rule fires).
|
|
129
|
+
const noDark = change(await fixture("ok.css"), " --line: #8a8a8a;\n", "");
|
|
130
|
+
const dropped = only((await check(noDark)).problems);
|
|
131
|
+
expect(dropped.rule).toBe("missing");
|
|
132
|
+
expect(dropped.message).toContain("`--line` is in the light block but not in the dark block");
|
|
133
|
+
expect(dropped.at).toBe(`tokens.css:${String(lineOf(noDark, "--line: #767676"))}`);
|
|
134
|
+
|
|
135
|
+
const noLight = change(await fixture("ok.css"), " --accent-ink: #fff;\n", "");
|
|
136
|
+
const unbound = only((await check(noLight)).problems);
|
|
137
|
+
expect(unbound.rule).toBe("missing");
|
|
138
|
+
expect(unbound.message).toContain("`--accent-ink` is in the dark block but not in the light");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reports a pair that names a token no block declares, once however many pairs use it", async () => {
|
|
142
|
+
const report = await check(await fixture("ok.css"), {
|
|
143
|
+
textPairs: [
|
|
144
|
+
["--ground", "--inkk"],
|
|
145
|
+
["--inkk", "--muted"],
|
|
146
|
+
["ground", "--ink"],
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
expect(report.problems.map((p) => p.rule)).toEqual(["missing", "missing"]);
|
|
150
|
+
expect(report.problems[0]?.message).toContain("`--inkk`, which neither the light nor");
|
|
151
|
+
expect(report.problems[1]?.message).toContain("`ground`");
|
|
152
|
+
expect(report.problems[1]?.message).toContain("start with --");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("fails a fallback that holds a different colour, naming both", async () => {
|
|
156
|
+
const css = change(await fixture("ok.css"), " --ink: #f2f2f2;", " --ink: #eeeeee;");
|
|
157
|
+
const found = only((await check(css)).problems);
|
|
158
|
+
expect(found.rule).toBe("mirror");
|
|
159
|
+
expect(found.message).toBe(
|
|
160
|
+
"`--ink` is #f2f2f2 in the dark block but #eeeeee in the system block",
|
|
161
|
+
);
|
|
162
|
+
expect(found.at).toBe(`tokens.css:${String(lineOf(css, "--ink: #eeeeee"))}`);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("fails a fallback that lacks a token, or holds one the dark block lacks", async () => {
|
|
166
|
+
const lacks = change(await fixture("ok.css"), " --line: #8a8a8a;\n", "");
|
|
167
|
+
expect(only((await check(lacks)).problems).message).toBe(
|
|
168
|
+
"`--line` is in the dark block but not in the system block",
|
|
169
|
+
);
|
|
170
|
+
const extra = change(
|
|
171
|
+
await fixture("ok.css"),
|
|
172
|
+
" --accent: #7ab7ff;",
|
|
173
|
+
" --accent: #7ab7ff;\n --extra: #000000;",
|
|
174
|
+
);
|
|
175
|
+
expect(only((await check(extra)).problems).message).toBe(
|
|
176
|
+
"`--extra` is in the system block but not in the dark block",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("fails a fallback written outside @media (prefers-color-scheme: dark)", async () => {
|
|
181
|
+
const css = change(
|
|
182
|
+
await fixture("ok.css"),
|
|
183
|
+
"@media (prefers-color-scheme: dark)",
|
|
184
|
+
"@media print",
|
|
185
|
+
);
|
|
186
|
+
const found = only((await check(css)).problems);
|
|
187
|
+
expect(found.rule).toBe("mirror");
|
|
188
|
+
expect(found.message).toContain("is not inside @media (prefers-color-scheme: dark)");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("fails a fallback that is not there, and reads none when the bundle names none", async () => {
|
|
192
|
+
const css = await fixture("ok.css");
|
|
193
|
+
const absent = only((await check(css, { system: ":root:not([data-mode])" })).problems);
|
|
194
|
+
expect(absent.rule).toBe("mirror");
|
|
195
|
+
expect(absent.message).toContain("no `:root:not([data-mode])` block");
|
|
196
|
+
|
|
197
|
+
const differs = change(css, " --ink: #f2f2f2;", " --ink: #eeeeee;");
|
|
198
|
+
expect((await check(differs, { system: undefined })).problems).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("fails a pair it cannot measure, and leaves tokens no pair uses alone", async () => {
|
|
202
|
+
// ok.css already carries `--radius: 8px` in the light block and passes: geometry is skipped.
|
|
203
|
+
const css = change(await fixture("ok.css"), "--muted: #555555", "--muted: rgb(85 85 85)");
|
|
204
|
+
const found = only((await check(css)).problems);
|
|
205
|
+
expect(found.rule).toBe("floor");
|
|
206
|
+
expect(found.message).toContain("cannot measure --muted (rgb(85 85 85))");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("a stylesheet or block that is not there fails the gate instead of passing it", async () => {
|
|
210
|
+
const css = await fixture("ok.css");
|
|
211
|
+
const noFile = await checkTokenPairs([bundle], { root: fixtures });
|
|
212
|
+
expect(only(noFile.problems)).toMatchObject({
|
|
213
|
+
rule: "load",
|
|
214
|
+
message: "tokens.css could not be read",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const noDark = only((await check(css, { dark: '[data-theme="night"]' })).problems);
|
|
218
|
+
expect(noDark.rule).toBe("load");
|
|
219
|
+
expect(noDark.message).toContain('no `[data-theme="night"]` block for the dark colours');
|
|
220
|
+
|
|
221
|
+
const same = only((await check(css, { light: bundle.dark })).problems);
|
|
222
|
+
expect(same.rule).toBe("load");
|
|
223
|
+
expect(same.message).toContain("must differ");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("does not read a block that holds another block", async () => {
|
|
227
|
+
const css = change(
|
|
228
|
+
await fixture("ok.css"),
|
|
229
|
+
" --accent-ink: #0a1a2b;\n}\n\n@media",
|
|
230
|
+
" --accent-ink: #0a1a2b;\n & a {\n color: red;\n }\n}\n\n@media",
|
|
231
|
+
);
|
|
232
|
+
expect(only((await check(css)).problems).rule).toBe("load");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("checkTokenPairs, light only", () => {
|
|
237
|
+
/** The fixture's light block alone: a stylesheet for a product with no dark theme. */
|
|
238
|
+
const lightOnly = async (): Promise<string> =>
|
|
239
|
+
(await fixture("ok.css")).split(':root[data-theme="dark"]')[0]!;
|
|
240
|
+
const light = { dark: undefined, system: undefined };
|
|
241
|
+
|
|
242
|
+
it("measures each pair once, in the light block, with no dark twin asked for", async () => {
|
|
243
|
+
const report = await check(await lightOnly(), light);
|
|
244
|
+
expect(report.problems).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("still fails a pair under its floor, as light", async () => {
|
|
248
|
+
const css = change(await lightOnly(), "--muted: #555555", "--muted: #bbbbbb");
|
|
249
|
+
const found = only((await check(css, light)).problems);
|
|
250
|
+
expect(found).toMatchObject({ rule: "floor" });
|
|
251
|
+
expect(found.message).toMatch(/^light: --muted \(#bbbbbb\) on --ground/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("reports a pair that names a token the light block lacks", async () => {
|
|
255
|
+
const found = only(
|
|
256
|
+
(await check(await lightOnly(), { ...light, textPairs: [["--ground", "--nope"]] })).problems,
|
|
257
|
+
);
|
|
258
|
+
expect(found).toMatchObject({ rule: "missing" });
|
|
259
|
+
expect(found.message).toContain("`--nope`, which the light block does not declare");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("refuses a system block with no dark block to repeat", async () => {
|
|
263
|
+
const found = only(
|
|
264
|
+
(await check(await lightOnly(), { dark: undefined, system: ":root:not([data-theme])" }))
|
|
265
|
+
.problems,
|
|
266
|
+
);
|
|
267
|
+
expect(found).toMatchObject({ rule: "load" });
|
|
268
|
+
expect(found.message).toContain("names no dark block");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("loadTokenThemes", () => {
|
|
273
|
+
it("hands back both palettes as hex, so an adopter can add a check of its own", async () => {
|
|
274
|
+
const { light, dark } = await loadTokenThemes(
|
|
275
|
+
{ css: "ok.css", dark: DARK },
|
|
276
|
+
{ root: fixtures },
|
|
277
|
+
);
|
|
278
|
+
expect(light.get("--ground")).toBe("#ffffff");
|
|
279
|
+
expect(dark.get("--ground")).toBe("#121212");
|
|
280
|
+
// `#fff` comes back as `#ffffff`, and `--radius: 8px` is geometry, not a colour.
|
|
281
|
+
expect(light.get("--accent-ink")).toBe("#ffffff");
|
|
282
|
+
expect(light.has("--radius")).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("throws, saying what is missing, rather than returning two empty palettes", async () => {
|
|
286
|
+
await expect(
|
|
287
|
+
loadTokenThemes({ css: "ok.css", dark: '[data-theme="night"]' }, { root: fixtures }),
|
|
288
|
+
).rejects.toThrow('no `[data-theme="night"]` block');
|
|
289
|
+
await expect(
|
|
290
|
+
loadTokenThemes({ css: "nope.css", dark: DARK }, { root: fixtures }),
|
|
291
|
+
).rejects.toThrow("nope.css could not be read");
|
|
292
|
+
});
|
|
293
|
+
});
|