@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 CHANGED
@@ -63,6 +63,87 @@ Four rules in there are load-bearing:
63
63
  points back at the others, which is why you pass the full list to every page rather than "the
64
64
  other two".
65
65
 
66
+ ## Discovery files
67
+
68
+ ```ts
69
+ import { jsonLd } from "@gusnips/vite";
70
+
71
+ jsonLd({ "@type": "SoftwareApplication", name: "Example" });
72
+ // A <script type="application/ld+json"> tag containing the object.
73
+ ```
74
+
75
+ Pass the result to `bakeHead` as `headExtra`. `jsonLd` escapes HTML control text while keeping
76
+ valid JSON. `faqJsonLd` uses the same serializer. You supply the schema.org fields, including
77
+ `SoftwareApplication` for an app or `WebAPI` for a public API. A local command is not a public API.
78
+
79
+ ```ts
80
+ import { llmsTxt, mcpDiscoveryJson, writeDist } from "@gusnips/vite";
81
+
82
+ await writeDist(
83
+ "dist",
84
+ "llms.txt",
85
+ llmsTxt({
86
+ title: "Example",
87
+ summary: "Read and update tasks.",
88
+ sections: [
89
+ {
90
+ title: "Reference",
91
+ links: [{ title: "Connect", url: "https://example.com/docs/mcp" }],
92
+ },
93
+ ],
94
+ }),
95
+ );
96
+
97
+ await writeDist(
98
+ "dist",
99
+ "llms-full.txt",
100
+ llmsTxt({
101
+ title: "Example reference",
102
+ body: referenceMarkdown,
103
+ }),
104
+ );
105
+
106
+ await writeDist(
107
+ "dist",
108
+ ".well-known/mcp.json",
109
+ mcpDiscoveryJson({
110
+ name: "Example",
111
+ homepage: "https://example.com",
112
+ documentation: "https://example.com/docs/mcp",
113
+ llmsTxt: "https://example.com/llms.txt",
114
+ llmsFullTxt: "https://example.com/llms-full.txt",
115
+ servers: [
116
+ {
117
+ name: "tasks",
118
+ transport: "streamable-http",
119
+ url: "https://api.example.com/mcp",
120
+ authentication: [
121
+ {
122
+ type: "oauth2",
123
+ protectedResourceMetadata:
124
+ "https://api.example.com/.well-known/oauth-protected-resource/mcp",
125
+ },
126
+ ],
127
+ tools: ["list_tasks", "update_task"],
128
+ },
129
+ ],
130
+ }),
131
+ );
132
+ ```
133
+
134
+ Build `referenceMarkdown` from your existing docs. The formatter adds no content or access rules.
135
+ Each server lists only its own tools, as names or objects with `name`, `title`, `description`,
136
+ `inputSchema` and `annotations`. Supplied schemas and annotations are kept unchanged.
137
+
138
+ For a local server, use `transport: "stdio"`, `command` and optional `args` instead of `url`.
139
+ `authentication` is a list of accepted methods: `oauth2` with `protectedResourceMetadata`, `api-key`
140
+ with `in` (`header` or `environment`), `name` and optional `prefix`, `session` with a `description`,
141
+ or `none` for public access. Methods can also include `description` and `documentation`.
142
+
143
+ `/.well-known/mcp.json` is discovery metadata, not a universal MCP standard or a client config.
144
+ It does not replace the live server's `tools/list`. These files do not promise search rankings or
145
+ support in a particular client.
146
+
66
147
  ## Rendering
67
148
 
68
149
  ```ts
@@ -289,7 +370,8 @@ process.exit(report.problems.some((p) => p.level === "error") ? 1 : 0);
289
370
  ```
290
371
 
291
372
  The tokens package measures the placeholder colours it ships. This measures yours: it compiles
292
- your entry with the real Tailwind CLI and reads the CSS that comes back.
373
+ your entry with the real Tailwind CLI and reads the CSS that comes back. No Tailwind? The next
374
+ section reads plain CSS custom properties instead.
293
375
 
294
376
  | It fails when | Because the reader gets |
295
377
  | ------------------------------------------------------------------ | --------------------------------------------------------- |
@@ -308,6 +390,108 @@ not have, or a class built dynamically that no scan can see.
308
390
  Pass `allow: [/^bg-brand-/]` for the classes a repo means. Floors take no allow list: a
309
391
  measured ratio is not a judgement call.
310
392
 
393
+ ## Check your contrast without Tailwind
394
+
395
+ If your colours are plain CSS custom properties, there is no `@theme` to compile. Name the file,
396
+ the selector of the dark block, and the pairs that must stay readable:
397
+
398
+ ```ts
399
+ // scripts/check-contrast.ts
400
+ import { checkTokenPairs, formatContrastReport } from "@gusnips/vite/contrast";
401
+
402
+ const report = await checkTokenPairs([
403
+ {
404
+ name: "site",
405
+ css: "src/tokens.css",
406
+ dark: ':root[data-theme="dark"]',
407
+ textPairs: [["--ground", "--ink"]],
408
+ },
409
+ ]);
410
+
411
+ console.log(formatContrastReport(report));
412
+ process.exit(report.problems.some((p) => p.level === "error") ? 1 : 0);
413
+ ```
414
+
415
+ It reads the colours in `:root { … }` as the light theme and the colours in your dark block as the
416
+ dark one. If `--ink` on `--ground` is under 4.5:1 in either, it prints the line and exits 1:
417
+
418
+ ```
419
+ site
420
+ ✖ floor: src/tokens.css:8 dark: --ink (#3a3a3a) on --ground (#121212) is 1.65:1, under the 4.5:1 floor for text (WCAG 1.4.3)
421
+ ✖ contrast: 1 error(s)
422
+ ```
423
+
424
+ Nothing is compiled, so it needs no Tailwind and no other peer. It returns the same report as
425
+ `checkContrast`, so the formatter is the same, and you can join the two before you exit:
426
+ `report.problems.push(...other.problems)`.
427
+
428
+ | It fails when | Because the reader gets |
429
+ | ----------------------------------------------------------------------- | ------------------------------------------------ |
430
+ | a pair is under its floor in the light or the dark theme | text or a border some people cannot see |
431
+ | a colour is in one theme and not the other | one mode keeping, or lacking, a colour |
432
+ | a pair names a token that no block declares | a typo that looks like a pair that passes |
433
+ | a pair holds a colour that is not hex | a floor nobody is keeping |
434
+ | the system block differs from the dark block, or is not in the `@media` | a page with no saved choice that looks different |
435
+
436
+ `textPairs` are held to 4.5:1 and `nonTextPairs` (borders, focus rings, icons) to 3:1. Change
437
+ them with `textFloor` and `nonTextFloor`. A pair is `[background, foreground]`; the ratio is the
438
+ same either way round, so the order only decides how the message reads. Write the names as the CSS
439
+ does, with the `--`. The light block is `:root` unless you set `light`.
440
+
441
+ **The system block.** A page that has not chosen a theme follows the system, so many apps write
442
+ the dark colours twice: once for the chosen theme and once for everyone else.
443
+
444
+ ```css
445
+ :root {
446
+ --ground: #ffffff;
447
+ --ink: #1a1a1a;
448
+ }
449
+
450
+ :root[data-theme="dark"] {
451
+ --ground: #121212;
452
+ --ink: #f2f2f2;
453
+ }
454
+
455
+ @media (prefers-color-scheme: dark) {
456
+ :root:not([data-theme]) {
457
+ --ground: #121212;
458
+ --ink: #f2f2f2;
459
+ }
460
+ }
461
+ ```
462
+
463
+ Pass `system: ":root:not([data-theme])"` and the check holds the second block to the first: the
464
+ same tokens, the same colours (`#FFF` and `#ffffff` are the same colour), and inside
465
+ `@media (prefers-color-scheme: dark)`. Either dark block can sit inside `@media screen { … }`.
466
+
467
+ **Your own checks.** `loadTokenThemes` hands back the two palettes as hex, with the same file and
468
+ selectors:
469
+
470
+ ```ts
471
+ import { loadTokenThemes } from "@gusnips/vite/contrast";
472
+
473
+ const { light, dark } = await loadTokenThemes({
474
+ css: "src/tokens.css",
475
+ dark: ':root[data-theme="dark"]',
476
+ });
477
+ light.get("--ground"); // "#ffffff"
478
+ ```
479
+
480
+ Use it for what only your app knows: that the browser's toolbar colour is the page's ground in
481
+ each theme, for example. It throws, and says what is missing, if the file or a block is not there.
482
+
483
+ **What it does not read.** It reads text, not CSS, and there is no CSS parser behind it.
484
+
485
+ - Only hex colours (`#fff` or `#ffffff`) are measured. A token in a pair that holds `rgb()`,
486
+ `oklch()` or `var()` fails as one it cannot measure. Tokens no pair uses, like `--radius`, are
487
+ left alone.
488
+ - A block is found by its selector text, inside any `@media`, and must hold declarations only. A
489
+ block with another rule inside it (nested CSS) is not read, and the check says it found no
490
+ block.
491
+ - A media query is read, not run. A `@media print` block with your dark selector counts as that
492
+ selector. If one selector is written twice, the later token wins, as it does in CSS.
493
+ - Light and dark need different selectors.
494
+
311
495
  ## The vite preset
312
496
 
313
497
  ```ts
@@ -12,7 +12,7 @@ export interface CheckContrastOptions {
12
12
  /** What `entry` and `sources` are relative to. Default: the working directory. */
13
13
  root?: string;
14
14
  }
15
- export type ContrastRule = "load" | "floor" | "retune" | "rebind" | "untokened" | "hover" | "dead";
15
+ export type ContrastRule = "load" | "floor" | "retune" | "rebind" | "untokened" | "hover" | "dead" | "missing" | "mirror";
16
16
  export interface ContrastProblem {
17
17
  rule: ContrastRule;
18
18
  /** A warning is shown and does not fail the gate. */
@@ -40,4 +40,63 @@ export declare function checkContrast(bundles: readonly ContrastBundle[], option
40
40
  /** The report as lines for a terminal: one per problem, grouped by bundle, and a last line that
41
41
  * says whether the gate passed. */
42
42
  export declare function formatContrastReport(report: ContrastReport): string;
43
+ /** Two custom properties, `[background, foreground]`, written as the CSS names them:
44
+ * `["--ground", "--ink"]`. The ratio is the same either way round; the order only decides how a
45
+ * problem reads. */
46
+ export type TokenPair = readonly [background: string, foreground: string];
47
+ export interface TokenPairsBundle {
48
+ /** Shown beside every problem: `"apps/site"`. */
49
+ name: string;
50
+ /** The stylesheet holding the colour blocks, relative to `root`. */
51
+ css: string;
52
+ /** The selector of the light block. Default `":root"`. It must differ from `dark`. */
53
+ light?: string;
54
+ /** The selector of the dark block: `':root[data-theme="dark"]'`. */
55
+ dark: string;
56
+ /**
57
+ * The selector of the system fallback: the dark colours again, for a page that has chosen no
58
+ * theme, inside `@media (prefers-color-scheme: dark)`: `":root:not([data-theme])"`. It must
59
+ * hold exactly the dark block's tokens and values. Leave it off when there is none.
60
+ */
61
+ system?: string;
62
+ /** Pairs read as text. Held to `textFloor`. */
63
+ textPairs?: readonly TokenPair[];
64
+ /** Pairs that are not text: borders, focus rings, icons. Held to `nonTextFloor`. */
65
+ nonTextPairs?: readonly TokenPair[];
66
+ /** Default 4.5 (WCAG 1.4.3). */
67
+ textFloor?: number;
68
+ /** Default 3 (WCAG 1.4.11). */
69
+ nonTextFloor?: number;
70
+ }
71
+ /** The hex colours of each theme, `--ground` → `#ffffff`, for a check of your own. */
72
+ export interface TokenThemes {
73
+ light: Map<string, string>;
74
+ dark: Map<string, string>;
75
+ }
76
+ /**
77
+ * The two palettes of a bundle as hex maps, for a check of your own that needs the colours
78
+ * (the browser's toolbar colour should be the page's ground, in each theme):
79
+ *
80
+ * ```ts
81
+ * const { light, dark } = await loadTokenThemes({ css: "src/tokens.css", dark: '[data-theme="dark"]' });
82
+ * light.get("--ground"); // "#ffffff"
83
+ * ```
84
+ *
85
+ * Throws, saying what is missing, when the file or one of the two blocks is not there.
86
+ */
87
+ export declare function loadTokenThemes(bundle: Pick<TokenPairsBundle, "css" | "light" | "dark">, options?: CheckContrastOptions): Promise<TokenThemes>;
88
+ /**
89
+ * The plain-CSS gate: for each stylesheet, the contrast of every pair in both themes, a token
90
+ * both themes have, and a system fallback that matches the dark block. Returns what it found,
91
+ * the same report `checkContrast` returns, so `formatContrastReport` prints either and a repo
92
+ * can join the two (`report.problems.push(...other.problems)`) and fail CI once.
93
+ *
94
+ * ```ts
95
+ * const report = await checkTokenPairs([
96
+ * { name: "site", css: "src/tokens.css", dark: ':root[data-theme="dark"]', textPairs: [["--ground", "--ink"]] },
97
+ * ]);
98
+ * console.log(formatContrastReport(report));
99
+ * ```
100
+ */
101
+ export declare function checkTokenPairs(bundles: readonly TokenPairsBundle[], options?: CheckContrastOptions): Promise<ContrastReport>;
43
102
  //# sourceMappingURL=contrast.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"AA0CA,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,kGAAkG;IAClG,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AAEnG,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IACnB,qDAAqD;IACrD,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AAyYD;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,cAAc,CAAC,CAIzB;AAED;oCACoC;AACpC,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAoBnE"}
1
+ {"version":3,"file":"contrast.d.ts","sourceRoot":"","sources":["../src/contrast.ts"],"names":[],"mappings":"AAqDA,MAAM,WAAW,cAAc;IAC7B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,kGAAkG;IAClG,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,YAAY,GACtB,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEjG,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IACnB,qDAAqD;IACrD,KAAK,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AA2YD;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,SAAS,cAAc,EAAE,EAClC,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,cAAc,CAAC,CAIzB;AAED;oCACoC;AACpC,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAoBnE;AAYD;;qBAEqB;AACrB,MAAM,MAAM,SAAS,GAAG,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;AAE1E,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IACjC,oFAAoF;IACpF,YAAY,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IACpC,gCAAgC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,sFAAsF;AACtF,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3B;AAmID;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,CAAC,EACxD,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,WAAW,CAAC,CAGtB;AA2ID;;;;;;;;;;;;GAYG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,gBAAgB,EAAE,EACpC,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,cAAc,CAAC,CAIzB"}
package/dist/contrast.js 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
- * Needs `@tailwindcss/cli` installed where it runs (an optional peer, reachable only from
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";
@@ -101,6 +112,14 @@ function measured(value, map) {
101
112
  const ATTR = /\bclass(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|`((?:[^`$]|\$(?!\{))*)`)/g;
102
113
  const HELPER = /\b(?:cn|clsx|cva|twMerge|twJoin)\s*\(([^)]*)\)/g;
103
114
  const STR = /"([^"]*)"|'([^']*)'/g;
115
+ /** The 1-based line a character offset falls on. */
116
+ function lineAt(source, index) {
117
+ let line = 1;
118
+ for (let i = 0; i < index; i++)
119
+ if (source.charCodeAt(i) === 10)
120
+ line++;
121
+ return line;
122
+ }
104
123
  const skipped = (file) => /(?:^|\/)__tests__\//.test(file) || /\.(test|spec)\.[tj]sx?$/.test(file);
105
124
  /** Every class string the sources use, with the first `file:line` that uses it. */
106
125
  async function scanSources(root, patterns) {
@@ -112,13 +131,6 @@ async function scanSources(root, patterns) {
112
131
  files.add(file);
113
132
  }
114
133
  }
115
- const lineAt = (source, index) => {
116
- let line = 1;
117
- for (let i = 0; i < index; i++)
118
- if (source.charCodeAt(i) === 10)
119
- line++;
120
- return line;
121
- };
122
134
  const keep = (raw, file, index, source) => {
123
135
  for (const cls of raw.split(/\s+/).filter(Boolean)) {
124
136
  // A token that would break the fixture's quoting is skipped rather than escaped:
@@ -426,4 +438,260 @@ export function formatContrastReport(report) {
426
438
  : `✖ contrast: ${errors} error(s)${warnings > 0 ? `, ${warnings} warning(s)` : ""}`);
427
439
  return lines.join("\n");
428
440
  }
441
+ const PREFERS_DARK = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/;
442
+ /** `:root[data-theme='dark']` and `:root[data-theme="dark"]` are the same selector. */
443
+ const selectorOf = (text) => text.replace(/\s+/g, " ").replace(/'/g, '"').trim();
444
+ /** The comparable form of a value: `#FFF` and `#ffffff` are one colour. */
445
+ const canonical = (value) => normalizeHex(value) ?? value.replace(/\s+/g, " ").toLowerCase();
446
+ /**
447
+ * Every innermost `selector { … }` block with the at-rules around it, and the custom properties
448
+ * it declares. Text, not syntax: a brace or semicolon inside a string or `url()` is not understood.
449
+ */
450
+ function leafRules(css) {
451
+ // Comments become spaces, so a `{` in one is not a block and every offset still names its line.
452
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "));
453
+ const rules = [];
454
+ const open = [];
455
+ let preludeFrom = 0;
456
+ for (let i = 0; i < source.length; i++) {
457
+ const char = source[i];
458
+ if (char === "{") {
459
+ const parent = open.at(-1);
460
+ if (parent !== undefined)
461
+ parent.nested = true;
462
+ open.push({ prelude: source.slice(preludeFrom, i).trim(), bodyAt: i + 1, nested: false });
463
+ preludeFrom = i + 1;
464
+ }
465
+ else if (char === "}") {
466
+ const block = open.pop();
467
+ preludeFrom = i + 1;
468
+ if (block === undefined || block.nested || block.prelude.startsWith("@"))
469
+ continue;
470
+ const tokens = new Map();
471
+ for (const match of source.slice(block.bodyAt, i).matchAll(/(--[\w-]+)\s*:\s*([^;]*)/g)) {
472
+ tokens.set(match[1] ?? "", {
473
+ value: (match[2] ?? "").trim(),
474
+ line: lineAt(source, block.bodyAt + match.index),
475
+ });
476
+ }
477
+ rules.push({
478
+ selectors: block.prelude.split(",").map(selectorOf),
479
+ within: open
480
+ .map((parent) => parent.prelude)
481
+ .filter((prelude) => prelude.startsWith("@"))
482
+ .join(" "),
483
+ tokens,
484
+ });
485
+ }
486
+ else if (char === ";") {
487
+ preludeFrom = i + 1;
488
+ }
489
+ }
490
+ return rules;
491
+ }
492
+ /** Every block written with this selector, merged: a later token wins, as it does in CSS. */
493
+ function blockOf(rules, selector) {
494
+ const wanted = selectorOf(selector);
495
+ const found = rules.filter((rule) => rule.selectors.includes(wanted));
496
+ if (found.length === 0)
497
+ return null;
498
+ const tokens = new Map();
499
+ for (const rule of found)
500
+ for (const [name, decl] of rule.tokens)
501
+ tokens.set(name, decl);
502
+ return { tokens, within: found.map((rule) => rule.within) };
503
+ }
504
+ /** Throws when the file or a block the bundle names is not there. The system block may be absent:
505
+ * whether that is a problem is the check's question, not the loader's. */
506
+ async function readTokenBlocks(bundle, root) {
507
+ const css = await readFile(resolve(root, bundle.css), "utf8").catch(() => null);
508
+ // A stylesheet that does not load reads as one with no tokens, which would pass every rule
509
+ // below. It fails instead, the same reason `checkContrast` has a load rule.
510
+ if (css === null)
511
+ throw new Error(`${bundle.css} could not be read`);
512
+ const rules = leafRules(css);
513
+ const required = (label, selector) => {
514
+ const block = blockOf(rules, selector);
515
+ if (block === null) {
516
+ throw new Error(`${bundle.css} has no \`${selector}\` block for the ${label} colours. A block is found by ` +
517
+ `its selector text, inside any @media, and holds declarations only`);
518
+ }
519
+ return block;
520
+ };
521
+ const lightSelector = bundle.light ?? ":root";
522
+ if (selectorOf(lightSelector) === selectorOf(bundle.dark)) {
523
+ throw new Error(`the light and dark selectors are both \`${lightSelector}\`; they must differ`);
524
+ }
525
+ return {
526
+ light: required("light", lightSelector),
527
+ dark: required("dark", bundle.dark),
528
+ system: bundle.system === undefined ? null : blockOf(rules, bundle.system),
529
+ };
530
+ }
531
+ /** The tokens whose value is a hex colour, as `#rrggbb`. Type and geometry are left out. */
532
+ function hexTokens(tokens) {
533
+ const hex = new Map();
534
+ for (const [name, { value }] of tokens) {
535
+ const colour = normalizeHex(value);
536
+ if (colour !== null)
537
+ hex.set(name, colour);
538
+ }
539
+ return hex;
540
+ }
541
+ /**
542
+ * The two palettes of a bundle as hex maps, for a check of your own that needs the colours
543
+ * (the browser's toolbar colour should be the page's ground, in each theme):
544
+ *
545
+ * ```ts
546
+ * const { light, dark } = await loadTokenThemes({ css: "src/tokens.css", dark: '[data-theme="dark"]' });
547
+ * light.get("--ground"); // "#ffffff"
548
+ * ```
549
+ *
550
+ * Throws, saying what is missing, when the file or one of the two blocks is not there.
551
+ */
552
+ export async function loadTokenThemes(bundle, options = {}) {
553
+ const { light, dark } = await readTokenBlocks(bundle, options.root ?? process.cwd());
554
+ return { light: hexTokens(light.tokens), dark: hexTokens(dark.tokens) };
555
+ }
556
+ async function checkTokenBundle(bundle, options, report) {
557
+ const problem = (p) => {
558
+ report.problems.push({ bundle: bundle.name, level: "error", ...p });
559
+ };
560
+ const at = (decl) => decl === undefined ? bundle.css : `${bundle.css}:${String(decl.line)}`;
561
+ let blocks;
562
+ try {
563
+ blocks = await readTokenBlocks(bundle, options.root ?? process.cwd());
564
+ }
565
+ catch (error) {
566
+ problem({ rule: "load", message: error instanceof Error ? error.message : String(error) });
567
+ return;
568
+ }
569
+ const { light, dark, system } = blocks;
570
+ const themes = [
571
+ ["light", light.tokens],
572
+ ["dark", dark.tokens],
573
+ ];
574
+ const textPairs = bundle.textPairs ?? [];
575
+ const nonTextPairs = bundle.nonTextPairs ?? [];
576
+ // ── Every colour has both themes, and every pair names a real token ──
577
+ const wanted = new Set([
578
+ ...hexTokens(light.tokens).keys(),
579
+ ...hexTokens(dark.tokens).keys(),
580
+ ...[...textPairs, ...nonTextPairs].flat(),
581
+ ]);
582
+ for (const token of wanted) {
583
+ const inLight = light.tokens.get(token);
584
+ const inDark = dark.tokens.get(token);
585
+ if (inLight !== undefined && inDark !== undefined)
586
+ continue;
587
+ if (inLight === undefined && inDark === undefined) {
588
+ problem({
589
+ rule: "missing",
590
+ at: bundle.css,
591
+ message: `a pair names \`${token}\`, which neither the light nor the dark block declares` +
592
+ (token.startsWith("--") ? "" : ". Custom property names start with --"),
593
+ });
594
+ continue;
595
+ }
596
+ const [have, lack] = inLight === undefined ? ["dark", "light"] : ["light", "dark"];
597
+ problem({
598
+ rule: "missing",
599
+ at: at(inLight ?? inDark),
600
+ message: `\`${token}\` is in the ${have} block but not in the ${lack} block. Declare it in both, ` +
601
+ `even when the colour is the same, so each theme says what it means`,
602
+ });
603
+ }
604
+ // ── The pairs, on the values that ship in each theme ──
605
+ // A token missing from a theme was reported above, so its pairs are skipped here, not repeated.
606
+ const unmeasurable = new Set();
607
+ const cannotMeasure = (mode, token, decl) => {
608
+ if (unmeasurable.has(`${mode}:${token}`))
609
+ return;
610
+ unmeasurable.add(`${mode}:${token}`);
611
+ problem({
612
+ rule: "floor",
613
+ at: at(decl),
614
+ message: `${mode}: cannot measure ${token} (${decl.value}), because it is not a hex colour. ` +
615
+ `Keep the values hex, or teach this check the new notation; ` +
616
+ `do not let a pair go unmeasured.`,
617
+ });
618
+ };
619
+ const measure = (pairs, floor, why) => {
620
+ for (const [background, foreground] of pairs) {
621
+ for (const [mode, tokens] of themes) {
622
+ const bg = tokens.get(background);
623
+ const fg = tokens.get(foreground);
624
+ if (bg === undefined || fg === undefined)
625
+ continue;
626
+ const bgHex = normalizeHex(bg.value);
627
+ const fgHex = normalizeHex(fg.value);
628
+ if (bgHex === null || fgHex === null) {
629
+ if (bgHex === null)
630
+ cannotMeasure(mode, background, bg);
631
+ if (fgHex === null)
632
+ cannotMeasure(mode, foreground, fg);
633
+ continue;
634
+ }
635
+ const ratio = contrast(fgHex, bgHex);
636
+ if (ratio < floor) {
637
+ problem({
638
+ rule: "floor",
639
+ at: at(fg),
640
+ message: `${mode}: ${foreground} (${fgHex}) on ${background} (${bgHex}) is ` +
641
+ `${ratio.toFixed(2)}:1, under the ${floor}:1 floor for ${why}`,
642
+ });
643
+ }
644
+ }
645
+ }
646
+ };
647
+ measure(textPairs, bundle.textFloor ?? 4.5, "text (WCAG 1.4.3)");
648
+ measure(nonTextPairs, bundle.nonTextFloor ?? 3, "a control or icon (WCAG 1.4.11)");
649
+ // ── The system fallback says what the dark block says ──
650
+ if (bundle.system === undefined)
651
+ return;
652
+ const mirror = (decl, message) => problem({ rule: "mirror", at: at(decl), message });
653
+ if (system === null) {
654
+ mirror(undefined, `no \`${bundle.system}\` block for the system fallback. Without it a page that has ` +
655
+ `chosen no theme stays light on a dark system`);
656
+ return;
657
+ }
658
+ if (!system.within.every((within) => PREFERS_DARK.test(within))) {
659
+ mirror([...system.tokens.values()][0], `\`${bundle.system}\` is not inside @media (prefers-color-scheme: dark), so it paints ` +
660
+ `the dark colours for everyone who has chosen no theme`);
661
+ }
662
+ for (const token of new Set([...dark.tokens.keys(), ...system.tokens.keys()])) {
663
+ const chosen = dark.tokens.get(token);
664
+ const followed = system.tokens.get(token);
665
+ if (chosen !== undefined && followed !== undefined) {
666
+ if (canonical(chosen.value) !== canonical(followed.value)) {
667
+ mirror(followed, `\`${token}\` is ${chosen.value} in the dark block but ${followed.value} in the system block`);
668
+ }
669
+ }
670
+ else if (chosen !== undefined) {
671
+ mirror(chosen, `\`${token}\` is in the dark block but not in the system block`);
672
+ }
673
+ else {
674
+ mirror(followed, `\`${token}\` is in the system block but not in the dark block`);
675
+ }
676
+ }
677
+ }
678
+ /**
679
+ * The plain-CSS gate: for each stylesheet, the contrast of every pair in both themes, a token
680
+ * both themes have, and a system fallback that matches the dark block. Returns what it found,
681
+ * the same report `checkContrast` returns, so `formatContrastReport` prints either and a repo
682
+ * can join the two (`report.problems.push(...other.problems)`) and fail CI once.
683
+ *
684
+ * ```ts
685
+ * const report = await checkTokenPairs([
686
+ * { name: "site", css: "src/tokens.css", dark: ':root[data-theme="dark"]', textPairs: [["--ground", "--ink"]] },
687
+ * ]);
688
+ * console.log(formatContrastReport(report));
689
+ * ```
690
+ */
691
+ export async function checkTokenPairs(bundles, options = {}) {
692
+ const report = { problems: [] };
693
+ for (const bundle of bundles)
694
+ await checkTokenBundle(bundle, options, report);
695
+ return report;
696
+ }
429
697
  //# sourceMappingURL=contrast.js.map