@gusnips/vite 0.8.32 → 0.8.33
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 +34 -0
- package/dist/contrast.d.ts +43 -0
- package/dist/contrast.d.ts.map +1 -0
- package/dist/contrast.js +429 -0
- package/dist/contrast.js.map +1 -0
- package/dist/head.d.ts +17 -0
- package/dist/head.d.ts.map +1 -1
- package/dist/head.js +22 -0
- package/dist/head.js.map +1 -1
- package/dist/icons.d.ts +61 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +72 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -7
- package/dist/index.js.map +1 -1
- package/dist/sitemap.d.ts +9 -0
- package/dist/sitemap.d.ts.map +1 -1
- package/dist/sitemap.js +17 -0
- package/dist/sitemap.js.map +1 -1
- package/package.json +15 -1
- package/src/contrast.test.ts +94 -0
- package/src/contrast.ts +508 -0
- package/src/fixtures/contrast/bad/page.html +4 -0
- package/src/fixtures/contrast/bad/theme.css +26 -0
- package/src/fixtures/contrast/ok/page.html +2 -0
- package/src/fixtures/contrast/ok/theme.css +29 -0
- package/src/head.test.ts +41 -1
- package/src/head.ts +31 -0
- package/src/icons.test.ts +135 -0
- package/src/icons.ts +140 -0
- package/src/index.ts +18 -5
- package/src/sitemap.test.ts +24 -0
- package/src/sitemap.ts +19 -0
package/src/contrast.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contrast gate: what a compiler cannot see in a theme and a set of class strings, checked
|
|
3
|
+
* where CI runs.
|
|
4
|
+
*
|
|
5
|
+
* Eight repos wrote this, in two halves that never met: one half measured token values (a
|
|
6
|
+
* control floor, `var()` chains), the other read class strings (untokened colours, the hover
|
|
7
|
+
* rung). Each half passed what the other would have caught, so this is the merge — one compile,
|
|
8
|
+
* both readings.
|
|
9
|
+
*
|
|
10
|
+
* The approach is the tokens package's compile check, extended to an adopter's values rather
|
|
11
|
+
* than duplicated beside it: write a fixture holding every class the sources use, run the real
|
|
12
|
+
* Tailwind CLI over the adopter's own entry, and read the CSS that comes back. The exit code
|
|
13
|
+
* proves nothing; the assertions below are the test. What cannot be imported is the reason:
|
|
14
|
+
* `tokens/` is a leaf with no JavaScript, so the few pure helpers this shares with its script
|
|
15
|
+
* (luminance, the rule lookup) are restated here, with their provenance, rather than reached
|
|
16
|
+
* for across the boundary.
|
|
17
|
+
*
|
|
18
|
+
* What this checks:
|
|
19
|
+
*
|
|
20
|
+
* - **floor**: `--color-input` clears 3:1 against `--color-background` in both modes (WCAG
|
|
21
|
+
* 1.4.11 — it is a control boundary), and `--color-foreground` clears 4.5:1. Three adopters
|
|
22
|
+
* failed the first, all under it, none visibly broken on screen.
|
|
23
|
+
* - **retune**: `--color-primary` carries its own dark value. Holding one brand colour across
|
|
24
|
+
* both modes lands the fill on the 3:1 line instead of clear of it.
|
|
25
|
+
* - **rebind**: when a `.dark` block exists, every token is rebound in it. The one that rots.
|
|
26
|
+
* - **untokened**: every colour a scanned class paints resolves to a token the adopter's own
|
|
27
|
+
* `@theme` declares. `text-white`, `bg-black/40`, `border-gray-300` all read a default-palette
|
|
28
|
+
* rung instead — correct in one mode and unreadable in the other.
|
|
29
|
+
* - **hover**: a state variant painting the same fill its base already has changes nothing.
|
|
30
|
+
* - **dead**: a class that compiles to no rule at all — a token Tailwind does not have, or a
|
|
31
|
+
* name the scanner caught half of. A warning: dynamic construction reads the same way.
|
|
32
|
+
*
|
|
33
|
+
* Runs on node and bun: `fs.promises.glob`, never Bun's `Glob`, and no `import.meta.dir`.
|
|
34
|
+
* Needs `@tailwindcss/cli` installed where it runs (an optional peer, reachable only from
|
|
35
|
+
* `@gusnips/vite/contrast`), because the fixture is compiled, not guessed.
|
|
36
|
+
*/
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import { glob, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
39
|
+
import { createRequire } from "node:module";
|
|
40
|
+
import { dirname, join, resolve } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
export interface ContrastBundle {
|
|
44
|
+
/** Shown beside every problem: `"apps/web"`. */
|
|
45
|
+
name: string;
|
|
46
|
+
/** The CSS file holding the adopter's `@theme`, relative to `root`. */
|
|
47
|
+
entry: string;
|
|
48
|
+
/** Source holding class strings, as globs relative to `root`. Tests are skipped. */
|
|
49
|
+
sources: readonly string[];
|
|
50
|
+
/** Classes the untokened rule skips, e.g. `[/^bg-brand-/]` for a marketing page that means it. */
|
|
51
|
+
allow?: readonly RegExp[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CheckContrastOptions {
|
|
55
|
+
/** What `entry` and `sources` are relative to. Default: the working directory. */
|
|
56
|
+
root?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ContrastRule = "load" | "floor" | "retune" | "rebind" | "untokened" | "hover" | "dead";
|
|
60
|
+
|
|
61
|
+
export interface ContrastProblem {
|
|
62
|
+
rule: ContrastRule;
|
|
63
|
+
/** A warning is shown and does not fail the gate. */
|
|
64
|
+
level: "error" | "warning";
|
|
65
|
+
bundle: string;
|
|
66
|
+
class?: string;
|
|
67
|
+
/** `file:line`, for a problem found in source. */
|
|
68
|
+
at?: string;
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ContrastReport {
|
|
73
|
+
problems: ContrastProblem[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Colour ────────────────────────────────────────────────────────────────────
|
|
77
|
+
// Restated from the tokens compile check (`tokens/scripts/compile-check.ts`), which this gate
|
|
78
|
+
// extends: a leaf ships no JavaScript, so there is nothing to import.
|
|
79
|
+
|
|
80
|
+
function luminance(hex: string): number {
|
|
81
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
82
|
+
const channel = (c: number): number => {
|
|
83
|
+
const v = c / 255;
|
|
84
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
85
|
+
};
|
|
86
|
+
return (
|
|
87
|
+
0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function contrast(a: string, b: string): number {
|
|
92
|
+
const [x, y] = [luminance(a), luminance(b)];
|
|
93
|
+
const [hi, lo] = x > y ? [x, y] : [y, x];
|
|
94
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `#fff` → `#ffffff`. Anything else hex-shaped passes through; anything else is null. */
|
|
98
|
+
function normalizeHex(value: string): string | null {
|
|
99
|
+
const hex = value.trim().toLowerCase();
|
|
100
|
+
if (/^#[0-9a-f]{6}$/.test(hex)) return hex;
|
|
101
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex);
|
|
102
|
+
return short === null
|
|
103
|
+
? null
|
|
104
|
+
: `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** `--color-*` declarations out of every `@theme { … }` block. Values stay raw. */
|
|
110
|
+
function themeBlock(entry: string, at: RegExp): Map<string, string> {
|
|
111
|
+
const found = new Map<string, string>();
|
|
112
|
+
for (const block of entry.matchAll(at)) {
|
|
113
|
+
for (const match of (block[1] ?? "").matchAll(/(--color-[\w-]+)\s*:\s*([^;]+);/g)) {
|
|
114
|
+
found.set(match[1] ?? "", (match[2] ?? "").trim());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Follow a `var()` chain to the literal it bottoms out at. Indirection is legitimate — a token
|
|
122
|
+
* may point at another — so only the terminal is measured, never the link. A cycle, a name the
|
|
123
|
+
* theme never declares, or a fallback that is itself unresolvable measures as nothing, which the
|
|
124
|
+
* floor reports rather than guesses.
|
|
125
|
+
*/
|
|
126
|
+
function resolveChain(
|
|
127
|
+
value: string,
|
|
128
|
+
map: Map<string, string>,
|
|
129
|
+
seen: Set<string> = new Set(),
|
|
130
|
+
): string | null {
|
|
131
|
+
const ref = /^\s*var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+?))?\s*\)\s*$/.exec(value);
|
|
132
|
+
if (ref === null) return value;
|
|
133
|
+
const target = map.get(ref[1] ?? "");
|
|
134
|
+
if (target === undefined || seen.has(ref[1] ?? "")) {
|
|
135
|
+
return ref[2] === undefined ? null : resolveChain(ref[2], map, seen);
|
|
136
|
+
}
|
|
137
|
+
seen.add(ref[1] ?? "");
|
|
138
|
+
return resolveChain(target, map, seen);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function measured(value: string, map: Map<string, string>): string | null {
|
|
142
|
+
const terminal = resolveChain(value, map);
|
|
143
|
+
return terminal === null ? null : normalizeHex(terminal);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Sources ───────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const ATTR = /\bclass(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|`((?:[^`$]|\$(?!\{))*)`)/g;
|
|
149
|
+
const HELPER = /\b(?:cn|clsx|cva|twMerge|twJoin)\s*\(([^)]*)\)/g;
|
|
150
|
+
const STR = /"([^"]*)"|'([^']*)'/g;
|
|
151
|
+
|
|
152
|
+
const skipped = (file: string): boolean =>
|
|
153
|
+
/(?:^|\/)__tests__\//.test(file) || /\.(test|spec)\.[tj]sx?$/.test(file);
|
|
154
|
+
|
|
155
|
+
/** Every class string the sources use, with the first `file:line` that uses it. */
|
|
156
|
+
async function scanSources(
|
|
157
|
+
root: string,
|
|
158
|
+
patterns: readonly string[],
|
|
159
|
+
): Promise<Map<string, { file: string; line: number }[]>> {
|
|
160
|
+
const found = new Map<string, { file: string; line: number }[]>();
|
|
161
|
+
const files = new Set<string>();
|
|
162
|
+
for (const pattern of patterns) {
|
|
163
|
+
for await (const file of glob(pattern, { cwd: root })) {
|
|
164
|
+
if (!skipped(file)) files.add(file);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
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
|
+
const keep = (raw: string, file: string, index: number, source: string): void => {
|
|
173
|
+
for (const cls of raw.split(/\s+/).filter(Boolean)) {
|
|
174
|
+
// A token that would break the fixture's quoting is skipped rather than escaped:
|
|
175
|
+
// Tailwind reads the fixture as TEXT, so an entity would read as a different class.
|
|
176
|
+
if (/["&<>]/.test(cls)) continue;
|
|
177
|
+
const at = { file, line: lineAt(source, index) };
|
|
178
|
+
const seen = found.get(cls);
|
|
179
|
+
if (seen === undefined) found.set(cls, [at]);
|
|
180
|
+
else if (!seen.some((entry) => entry.file === file)) seen.push(at);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
for (const file of [...files].sort()) {
|
|
184
|
+
const source = await readFile(resolve(root, file), "utf8");
|
|
185
|
+
for (const match of source.matchAll(ATTR)) {
|
|
186
|
+
keep(match[1] ?? match[2] ?? match[3] ?? "", file, match.index, source);
|
|
187
|
+
}
|
|
188
|
+
for (const call of source.matchAll(HELPER)) {
|
|
189
|
+
for (const match of (call[1] ?? "").matchAll(STR)) {
|
|
190
|
+
keep(match[1] ?? match[2] ?? "", file, (call.index ?? 0) + (match.index ?? 0), source);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Compile ───────────────────────────────────────────────────────────────────
|
|
198
|
+
// The tokens approach: a fixture holding every class, the real CLI over the adopter's own
|
|
199
|
+
// entry, and the CSS that comes back. The fixture is plain text, not HTML — Tailwind reads
|
|
200
|
+
// candidates out of the raw bytes, so no quoting can corrupt a class.
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The body of the first rule whose selector uses `cls` as a whole class name.
|
|
204
|
+
* Follows the tokens compile check: `.text-card` must not match `.text-card-foreground`.
|
|
205
|
+
*/
|
|
206
|
+
function ruleBody(css: string, cls: string): string | null {
|
|
207
|
+
const selector = `.${cls.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`)}`;
|
|
208
|
+
let from = 0;
|
|
209
|
+
for (;;) {
|
|
210
|
+
const at = css.indexOf(selector, from);
|
|
211
|
+
if (at === -1) return null;
|
|
212
|
+
from = at + selector.length;
|
|
213
|
+
if (/^[-\w\\]/.test(css.slice(from))) continue;
|
|
214
|
+
const open = css.indexOf("{", at);
|
|
215
|
+
if (open === -1) return null;
|
|
216
|
+
let depth = 0;
|
|
217
|
+
for (let i = open; i < css.length; i++) {
|
|
218
|
+
if (css[i] === "{") depth++;
|
|
219
|
+
else if (css[i] === "}" && --depth === 0) return css.slice(open + 1, i);
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function compile(entryAbs: string, classes: string[], workDir: string): Promise<string> {
|
|
226
|
+
// `createRequire` and not `import.meta.resolve`: this runs in whatever runtime the adopter's
|
|
227
|
+
// CI uses, and `require.resolve` holds everywhere. Never `import.meta.dir` — it is bun-only.
|
|
228
|
+
const cliPkg = createRequire(fileURLToPath(import.meta.url)).resolve(
|
|
229
|
+
"@tailwindcss/cli/package.json",
|
|
230
|
+
);
|
|
231
|
+
const cli = join(dirname(cliPkg), "dist", "index.mjs");
|
|
232
|
+
|
|
233
|
+
// The entry must live where `tailwindcss` resolves: beside the adopter's tree, which the
|
|
234
|
+
// workdir is. The adopter's own entry is imported by absolute path, so its `@source`
|
|
235
|
+
// directives — relative to the file it is reading — resolve the way its build resolves them.
|
|
236
|
+
await mkdir(workDir, { recursive: true });
|
|
237
|
+
await writeFile(join(workDir, "fixture.txt"), `${[...classes].sort().join("\n")}\n`);
|
|
238
|
+
await writeFile(
|
|
239
|
+
join(workDir, "entry.css"),
|
|
240
|
+
`@import "tailwindcss";\n@import "${entryAbs}";\n@source "./fixture.txt";\n`,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const out = join(workDir, "out.css");
|
|
244
|
+
const run = spawnSync(process.execPath, [cli, "-i", join(workDir, "entry.css"), "-o", out], {
|
|
245
|
+
cwd: workDir,
|
|
246
|
+
encoding: "utf-8",
|
|
247
|
+
// The compile takes seconds. The CLI has hung once with no output, and with no deadline
|
|
248
|
+
// that held a gate open for five minutes instead of failing it.
|
|
249
|
+
timeout: 120_000,
|
|
250
|
+
});
|
|
251
|
+
if (run.error !== undefined) throw new Error(`tailwindcss did not finish: ${run.error.message}`);
|
|
252
|
+
if (run.status !== 0) {
|
|
253
|
+
// Left on disk on purpose: the compiled CSS is the first thing you want to read.
|
|
254
|
+
throw new Error(
|
|
255
|
+
`tailwindcss failed to compile ${entryAbs}${run.stderr ? `: ${run.stderr.slice(0, 500)}` : ""} (workdir kept at ${workDir})`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const css = await readFile(out, "utf-8");
|
|
259
|
+
await rm(workDir, { recursive: true, force: true });
|
|
260
|
+
return css;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── The check ─────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
const COLOR_PROPS = new Set([
|
|
266
|
+
"color",
|
|
267
|
+
"background-color",
|
|
268
|
+
"border-color",
|
|
269
|
+
"border-top-color",
|
|
270
|
+
"border-right-color",
|
|
271
|
+
"border-bottom-color",
|
|
272
|
+
"border-left-color",
|
|
273
|
+
"outline-color",
|
|
274
|
+
"text-decoration-color",
|
|
275
|
+
"fill",
|
|
276
|
+
"stroke",
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
const LITERAL_COLOR = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl|oklch|oklab|lab|lch|color-mix|color)\s*\(/;
|
|
280
|
+
|
|
281
|
+
/** The first `file:line` that uses a class, for problems found in source. */
|
|
282
|
+
function firstAt(
|
|
283
|
+
scanned: Map<string, { file: string; line: number }[]>,
|
|
284
|
+
cls: string,
|
|
285
|
+
): string | undefined {
|
|
286
|
+
const at = scanned.get(cls)?.[0];
|
|
287
|
+
return at === undefined ? undefined : `${at.file}:${String(at.line)}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `hover:bg-primary` → `bg-primary`. Every variant prefix comes off, not just the first. */
|
|
291
|
+
function stem(cls: string): string {
|
|
292
|
+
return cls.replace(/^(?:[a-z][a-z0-9-]*:)+/, "");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isStateVariant(cls: string): boolean {
|
|
296
|
+
return /^(?:hover|focus|focus-visible|focus-within|active):/.test(cls);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function checkBundle(
|
|
300
|
+
bundle: ContrastBundle,
|
|
301
|
+
options: CheckContrastOptions,
|
|
302
|
+
report: ContrastReport,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const root = options.root ?? process.cwd();
|
|
305
|
+
const problem = (p: Omit<ContrastProblem, "bundle" | "level"> & { level?: "warning" }): void => {
|
|
306
|
+
report.problems.push({ bundle: bundle.name, level: "error", ...p });
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const entryAbs = resolve(root, bundle.entry);
|
|
310
|
+
const entry = await readFile(entryAbs, "utf8").catch(() => null);
|
|
311
|
+
if (entry === null) {
|
|
312
|
+
// A theme that does not load reads as one with no tokens, which would pass every rule
|
|
313
|
+
// below. It fails here instead — the i18n gate's load rule, for the same reason.
|
|
314
|
+
problem({ rule: "load", message: `${bundle.entry} could not be read` });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const light = themeBlock(entry, /@theme\s*\{([^}]*)\}/g);
|
|
319
|
+
const dark = themeBlock(entry, /\.dark\s*\{([^}]*)\}/g);
|
|
320
|
+
const semantic = new Set(light.keys());
|
|
321
|
+
|
|
322
|
+
// ── Floors, on the values that actually ship ──
|
|
323
|
+
const floors: Array<[token: string, against: string, floor: number, why: string]> = [
|
|
324
|
+
["--color-input", "--color-background", 3, "a field border (WCAG 1.4.11)"],
|
|
325
|
+
["--color-foreground", "--color-background", 4.5, "body copy (WCAG 1.4.3)"],
|
|
326
|
+
];
|
|
327
|
+
const modes: Array<[mode: string, map: Map<string, string>]> = [["light", light]];
|
|
328
|
+
if (dark.size > 0) modes.push(["dark", dark]);
|
|
329
|
+
for (const [mode, map] of modes) {
|
|
330
|
+
for (const [token, against, floor, why] of floors) {
|
|
331
|
+
const fg = map.has(token) ? measured(map.get(token) ?? "", map) : null;
|
|
332
|
+
const bg = map.has(against) ? measured(map.get(against) ?? "", map) : null;
|
|
333
|
+
if (fg === null || bg === null) {
|
|
334
|
+
problem({
|
|
335
|
+
rule: "floor",
|
|
336
|
+
message:
|
|
337
|
+
`${mode}: cannot measure ${token} on ${against} — one of them is no longer a ` +
|
|
338
|
+
`hex colour. Keep the values hex, or teach this check the new notation; ` +
|
|
339
|
+
`do not let the floor go unmeasured.`,
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const ratio = contrast(fg, bg);
|
|
344
|
+
if (ratio < floor) {
|
|
345
|
+
problem({
|
|
346
|
+
rule: "floor",
|
|
347
|
+
message:
|
|
348
|
+
`${mode}: ${token} (${fg}) on ${against} (${bg}) is ${ratio.toFixed(2)}:1, ` +
|
|
349
|
+
`under the ${floor}:1 floor for ${why}`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (dark.size > 0) {
|
|
356
|
+
for (const name of light.keys()) {
|
|
357
|
+
if (!dark.has(name))
|
|
358
|
+
problem({
|
|
359
|
+
rule: "rebind",
|
|
360
|
+
message: `\`${name}\` is missing from the .dark block — one mode keeps the light value`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
// A fill chosen against a white page lands on the 3:1 line on a near-black card instead of
|
|
364
|
+
// clear of it. The day a brand colour truly works in both modes, this is the line that says
|
|
365
|
+
// the contract moved.
|
|
366
|
+
const day = measured(light.get("--color-primary") ?? "", light);
|
|
367
|
+
const night = measured(dark.get("--color-primary") ?? "", dark);
|
|
368
|
+
if (day !== null && night !== null && day === night) {
|
|
369
|
+
problem({
|
|
370
|
+
rule: "retune",
|
|
371
|
+
message:
|
|
372
|
+
`--color-primary (${day}) is the same fill in both modes — lift it for dark, and ` +
|
|
373
|
+
`flip --color-primary-foreground with it`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Classes, against the compiled output ──
|
|
379
|
+
const scanned = await scanSources(root, bundle.sources);
|
|
380
|
+
const classes = [...scanned.keys()];
|
|
381
|
+
let css: string;
|
|
382
|
+
try {
|
|
383
|
+
css = await compile(entryAbs, classes, join(root, ".contrast-check"));
|
|
384
|
+
} catch (error) {
|
|
385
|
+
problem({
|
|
386
|
+
rule: "load",
|
|
387
|
+
message: error instanceof Error ? error.message : String(error),
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const allowed = (cls: string): boolean => (bundle.allow ?? []).some((re) => re.test(cls));
|
|
393
|
+
const byStem = new Map<string, Set<string>>();
|
|
394
|
+
for (const cls of classes) {
|
|
395
|
+
const set = byStem.get(stem(cls)) ?? new Set<string>();
|
|
396
|
+
set.add(cls);
|
|
397
|
+
byStem.set(stem(cls), set);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const cls of classes.sort()) {
|
|
401
|
+
const body = ruleBody(css, cls);
|
|
402
|
+
if (body === null) {
|
|
403
|
+
problem({
|
|
404
|
+
rule: "dead",
|
|
405
|
+
level: "warning",
|
|
406
|
+
class: cls,
|
|
407
|
+
at: firstAt(scanned, cls),
|
|
408
|
+
message:
|
|
409
|
+
"compiled to no rule — a token Tailwind does not have, or a class built dynamically",
|
|
410
|
+
});
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (allowed(cls)) continue;
|
|
414
|
+
for (const declaration of body.split(";")) {
|
|
415
|
+
const colon = declaration.indexOf(":");
|
|
416
|
+
if (colon === -1) continue;
|
|
417
|
+
const prop = declaration.slice(0, colon).trim();
|
|
418
|
+
if (!COLOR_PROPS.has(prop)) continue;
|
|
419
|
+
const value = declaration.slice(colon + 1);
|
|
420
|
+
const tokens = [...value.matchAll(/var\(\s*(--color-[\w-]+)/g)].map((m) => m[1] ?? "");
|
|
421
|
+
if (tokens.length === 0) {
|
|
422
|
+
if (!LITERAL_COLOR.test(value)) continue;
|
|
423
|
+
for (const { file, line } of scanned.get(cls) ?? []) {
|
|
424
|
+
problem({
|
|
425
|
+
rule: "untokened",
|
|
426
|
+
class: cls,
|
|
427
|
+
at: `${file}:${String(line)}`,
|
|
428
|
+
message: `paints ${prop} with a literal colour — point it at a theme token instead`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
for (const token of tokens) {
|
|
434
|
+
if (semantic.has(token)) continue;
|
|
435
|
+
for (const { file, line } of scanned.get(cls) ?? []) {
|
|
436
|
+
problem({
|
|
437
|
+
rule: "untokened",
|
|
438
|
+
class: cls,
|
|
439
|
+
at: `${file}:${String(line)}`,
|
|
440
|
+
message:
|
|
441
|
+
`paints ${prop} from \`${token}\`, which the theme never declares — ` +
|
|
442
|
+
`a default-palette rung that reads right in one mode and vanishes in the other`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── The hover rung ──
|
|
450
|
+
for (const [base, variants] of byStem) {
|
|
451
|
+
if (!variants.has(base)) continue;
|
|
452
|
+
for (const variant of [...variants].sort()) {
|
|
453
|
+
if (variant === base || !isStateVariant(variant) || stem(variant) !== base) continue;
|
|
454
|
+
problem({
|
|
455
|
+
rule: "hover",
|
|
456
|
+
level: "warning",
|
|
457
|
+
class: variant,
|
|
458
|
+
at: firstAt(scanned, variant),
|
|
459
|
+
message:
|
|
460
|
+
`paints the same ${base} the base state already has — the state changes nothing. ` +
|
|
461
|
+
`If these are on different elements, ignore this.`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Run the gate over every bundle. Returns what it found rather than printing or exiting, so a
|
|
469
|
+
* repo can add checks of its own to the same report and decide how CI fails:
|
|
470
|
+
*
|
|
471
|
+
* ```ts
|
|
472
|
+
* const report = await checkContrast([{ name: "apps/web", entry: "src/index.css", sources: ["src/**"] }]);
|
|
473
|
+
* console.log(formatContrastReport(report));
|
|
474
|
+
* process.exit(report.problems.some((p) => p.level === "error") ? 1 : 0);
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
export async function checkContrast(
|
|
478
|
+
bundles: readonly ContrastBundle[],
|
|
479
|
+
options: CheckContrastOptions = {},
|
|
480
|
+
): Promise<ContrastReport> {
|
|
481
|
+
const report: ContrastReport = { problems: [] };
|
|
482
|
+
for (const bundle of bundles) await checkBundle(bundle, options, report);
|
|
483
|
+
return report;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The report as lines for a terminal: one per problem, grouped by bundle, and a last line that
|
|
487
|
+
* says whether the gate passed. */
|
|
488
|
+
export function formatContrastReport(report: ContrastReport): string {
|
|
489
|
+
const lines: string[] = [];
|
|
490
|
+
const bundles = [...new Set(report.problems.map((p) => p.bundle))];
|
|
491
|
+
for (const bundle of bundles) {
|
|
492
|
+
lines.push(bundle);
|
|
493
|
+
for (const p of report.problems.filter((q) => q.bundle === bundle)) {
|
|
494
|
+
const where = [p.class, p.at].filter((part) => part !== undefined).join(" ");
|
|
495
|
+
lines.push(
|
|
496
|
+
` ${p.level === "error" ? "✖" : "⚠"} ${p.rule}: ${where}${where === "" ? "" : " "}${p.message}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const errors = report.problems.filter((p) => p.level === "error").length;
|
|
501
|
+
const warnings = report.problems.length - errors;
|
|
502
|
+
lines.push(
|
|
503
|
+
errors === 0
|
|
504
|
+
? `✔ contrast: no errors${warnings > 0 ? `, ${warnings} warning(s)` : ""}`
|
|
505
|
+
: `✖ contrast: ${errors} error(s)${warnings > 0 ? `, ${warnings} warning(s)` : ""}`,
|
|
506
|
+
);
|
|
507
|
+
return lines.join("\n");
|
|
508
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@theme {
|
|
2
|
+
--color-background: #ffffff;
|
|
3
|
+
--color-foreground: #1a1a1a;
|
|
4
|
+
--color-card: #ffffff;
|
|
5
|
+
--color-card-foreground: #1a1a1a;
|
|
6
|
+
--color-primary: #7c3aed;
|
|
7
|
+
--color-primary-foreground: #ffffff;
|
|
8
|
+
--color-accent: #f5f3ff;
|
|
9
|
+
--color-border: #e5e5e5;
|
|
10
|
+
--color-input: #d4d4d4;
|
|
11
|
+
--color-ring: #7c3aed;
|
|
12
|
+
--color-scrim: #171717;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.dark {
|
|
16
|
+
--color-background: #0a0a0a;
|
|
17
|
+
--color-foreground: #fafafa;
|
|
18
|
+
--color-card: #0a0a0a;
|
|
19
|
+
--color-card-foreground: #fafafa;
|
|
20
|
+
--color-primary: #7c3aed;
|
|
21
|
+
--color-primary-foreground: #ffffff;
|
|
22
|
+
--color-accent: #2e1065;
|
|
23
|
+
--color-border: #262626;
|
|
24
|
+
--color-input: #404040;
|
|
25
|
+
--color-ring: #a78bfa;
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@theme {
|
|
2
|
+
--color-background: #ffffff;
|
|
3
|
+
--color-foreground: #1a1a1a;
|
|
4
|
+
--color-card: #ffffff;
|
|
5
|
+
--color-card-foreground: #1a1a1a;
|
|
6
|
+
--color-primary: #7c3aed;
|
|
7
|
+
--color-primary-foreground: #ffffff;
|
|
8
|
+
--color-accent: #f5f3ff;
|
|
9
|
+
--color-accent-foreground: #4c1d95;
|
|
10
|
+
--color-border: #e5e5e5;
|
|
11
|
+
--color-input: #525252;
|
|
12
|
+
--color-ring: #7c3aed;
|
|
13
|
+
--color-scrim: #171717;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.dark {
|
|
17
|
+
--color-background: #0a0a0a;
|
|
18
|
+
--color-foreground: #fafafa;
|
|
19
|
+
--color-card: #0a0a0a;
|
|
20
|
+
--color-card-foreground: #fafafa;
|
|
21
|
+
--color-primary: #a78bfa;
|
|
22
|
+
--color-primary-foreground: #1e1b4b;
|
|
23
|
+
--color-accent: #2e1065;
|
|
24
|
+
--color-accent-foreground: #ede9fe;
|
|
25
|
+
--color-border: #262626;
|
|
26
|
+
--color-input: #a3a3a3;
|
|
27
|
+
--color-ring: #a78bfa;
|
|
28
|
+
--color-scrim: #f5f5f5;
|
|
29
|
+
}
|
package/src/head.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react";
|
|
3
|
-
import { assertRendered, bakeHead, ogLocale } from "./head.ts";
|
|
3
|
+
import { assertRendered, bakeHead, faqJsonLd, ogLocale } from "./head.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Every failure `bakeHead` has is invisible until somebody reads a search result months later
|
|
@@ -432,3 +432,43 @@ describe("assertRendered", () => {
|
|
|
432
432
|
);
|
|
433
433
|
});
|
|
434
434
|
});
|
|
435
|
+
|
|
436
|
+
describe("faqJsonLd", () => {
|
|
437
|
+
// One donor's template carried four questions where the page renders seven: the fix is to
|
|
438
|
+
// generate the block from the same strings, not to keep a second copy beside them.
|
|
439
|
+
it("writes one Question per entry, with the page's own strings", () => {
|
|
440
|
+
const script = faqJsonLd([
|
|
441
|
+
{ question: "Can my number get banned?", answer: "Yes, if you spam." },
|
|
442
|
+
{ question: "Is there a free plan?", answer: "Yes." },
|
|
443
|
+
]);
|
|
444
|
+
expect(script.startsWith('<script type="application/ld+json">')).toBe(true);
|
|
445
|
+
expect(script).toContain('"@type":"FAQPage"');
|
|
446
|
+
expect(script).toContain('"name":"Can my number get banned?"');
|
|
447
|
+
expect(script).toContain('"text":"Yes, if you spam."');
|
|
448
|
+
expect(script.match(/"@type":"Question"/g)).toHaveLength(2);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// A `</script>` inside an answer ends the element early and ships the rest of the JSON as
|
|
452
|
+
// markup. `<\/` is the same string to a JSON parser and inert to an HTML one.
|
|
453
|
+
it("escapes a script-closing sequence inside an answer", () => {
|
|
454
|
+
const script = faqJsonLd([
|
|
455
|
+
{ question: "What if I write code?", answer: "Wrap it: </script> ends the block." },
|
|
456
|
+
]);
|
|
457
|
+
expect(script).not.toContain("</script> ends");
|
|
458
|
+
expect(script).toContain("<\\/script> ends");
|
|
459
|
+
// …and what it emits still parses, back to the original copy.
|
|
460
|
+
const json = script.replace(/^<script[^>]*>/, "").replace(/<\/script>$/, "");
|
|
461
|
+
const parsed = JSON.parse(json) as {
|
|
462
|
+
mainEntity: Array<{ acceptedAnswer: { text: string } }>;
|
|
463
|
+
};
|
|
464
|
+
expect(parsed.mainEntity[0]?.acceptedAnswer.text).toBe("Wrap it: </script> ends the block.");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// The positive control for the direction that flatters: an answer WITH markup must fail this
|
|
468
|
+
// test if the escaping ever stops running, not pass because the assertion went missing.
|
|
469
|
+
it("leaves no raw `</` sequence anywhere in the block", () => {
|
|
470
|
+
const script = faqJsonLd([{ question: "q", answer: "a </b> b <!-- c" }]);
|
|
471
|
+
const inner = script.replace(/<script[^>]*>/g, "").replace(/<\/script>/g, "");
|
|
472
|
+
expect(inner).not.toMatch(/<\//);
|
|
473
|
+
});
|
|
474
|
+
});
|
package/src/head.ts
CHANGED
|
@@ -185,6 +185,37 @@ export function ogLocale(tag: string): string {
|
|
|
185
185
|
return TERRITORY[tag] ?? tag.replace("-", "_");
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** One question and its answer, as the page renders them — pass the same strings, so the
|
|
189
|
+
* structured data cannot drift from the copy. One donor's template carried four questions
|
|
190
|
+
* where the page renders seven, and nothing compared them until the migration. */
|
|
191
|
+
export interface FaqEntry {
|
|
192
|
+
question: string;
|
|
193
|
+
answer: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* FAQ structured data for `headExtra`, with the script-closing sequence escaped.
|
|
198
|
+
*
|
|
199
|
+
* A `</script>` inside an answer ends the element early and ships the rest of the JSON as
|
|
200
|
+
* markup. `<\/` is the same string to a JSON parser and inert to an HTML one, so every `</`
|
|
201
|
+
* becomes `<\/` and `<!--` becomes `<\!--`. `JSON.stringify` never emits `<` outside a string
|
|
202
|
+
* — the structural characters are all brackets, quotes and digits — so the replace only ever
|
|
203
|
+
* touches copy.
|
|
204
|
+
*/
|
|
205
|
+
export function faqJsonLd(faqs: readonly FaqEntry[]): string {
|
|
206
|
+
const data = {
|
|
207
|
+
"@context": "https://schema.org",
|
|
208
|
+
"@type": "FAQPage",
|
|
209
|
+
mainEntity: faqs.map((faq) => ({
|
|
210
|
+
"@type": "Question",
|
|
211
|
+
name: faq.question,
|
|
212
|
+
acceptedAnswer: { "@type": "Answer", text: faq.answer },
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
const json = JSON.stringify(data).replace(/<\//g, "<\\/").replace(/<!--/g, "<\\!--");
|
|
216
|
+
return `<script type="application/ld+json">${json}</script>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
/**
|
|
189
220
|
* One page's `<head>` and body, written into the built template.
|
|
190
221
|
*
|