tailwind-a11y 0.3.1 → 0.4.0
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 +14 -6
- package/dist/cli.js +17 -4
- package/dist/cliArgs.d.ts +2 -0
- package/dist/cliArgs.js +32 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/parser/extractTouchTargets.d.ts +2 -2
- package/dist/parser/extractTouchTargets.js +7 -7
- package/dist/rules/checkContrast.d.ts +5 -4
- package/dist/rules/checkContrast.js +12 -12
- package/dist/theme/loadCustomTheme.d.ts +18 -0
- package/dist/theme/loadCustomTheme.js +149 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,13 +22,19 @@ npm install --save-dev tailwind-a11y
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
npx tailwind-a11y
|
|
26
|
-
npx tailwind-a11y "src/**/*.tsx"
|
|
27
|
-
npx tailwind-a11y --verbose
|
|
28
|
-
npx tailwind-a11y --
|
|
29
|
-
npx tailwind-a11y --
|
|
25
|
+
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
26
|
+
npx tailwind-a11y "src/**/*.tsx" # custom glob
|
|
27
|
+
npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
|
|
28
|
+
npx tailwind-a11y --config ./tw.config.cjs # use a specific tailwind.config instead of auto-detecting
|
|
29
|
+
npx tailwind-a11y --version # print the installed version
|
|
30
|
+
npx tailwind-a11y --help # usage and all options
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
Custom `theme.extend.colors`/`theme.extend.spacing` in a `tailwind.config.js`/`.cjs`
|
|
34
|
+
found in the current directory (or passed via `--config`) are read automatically, so
|
|
35
|
+
colors and spacing outside Tailwind's defaults resolve too — not just the built-in
|
|
36
|
+
palette.
|
|
37
|
+
|
|
32
38
|
```
|
|
33
39
|
src/components/Card.tsx
|
|
34
40
|
3: text-gray-400 on bg-white — ratio 2.54, needs 4.5 (AA); try text-gray-500 (4.83)
|
|
@@ -54,7 +60,9 @@ When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
|
54
60
|
|
|
55
61
|
- Ancestors beyond the immediate parent, or backgrounds set inside a separate component
|
|
56
62
|
- Dynamic or computed `className` (ternaries, `clsx()`, template literals)
|
|
57
|
-
-
|
|
63
|
+
- A full `theme.colors`/`theme.spacing` replacement, `.mjs`/`.ts` configs, or
|
|
64
|
+
Tailwind v4's CSS-based `@theme` config (only `theme.extend` in a `.js`/`.cjs`
|
|
65
|
+
config is read — see [CLAUDE.md](./CLAUDE.md))
|
|
58
66
|
- Color + opacity shorthand (`bg-white/50`)
|
|
59
67
|
- Frameworks other than React/JSX
|
|
60
68
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { relative, sep } from "node:path";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import fg from "fast-glob";
|
|
6
6
|
import { extractChecks, extractContrastSkips } from "./parser/extractClasses.js";
|
|
@@ -10,6 +10,7 @@ import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
|
10
10
|
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
11
11
|
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
12
12
|
import { parseArgs, getHelpText } from "./cliArgs.js";
|
|
13
|
+
import { resolveTheme } from "./theme/loadCustomTheme.js";
|
|
13
14
|
// ../package.json resolves correctly from both src/ (dev) and dist/ (published).
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const { version: packageVersion } = require("../package.json");
|
|
@@ -39,7 +40,7 @@ function groupByFile(items) {
|
|
|
39
40
|
return byFile;
|
|
40
41
|
}
|
|
41
42
|
async function main() {
|
|
42
|
-
const { help, version, verbose, patterns } = parseArgs(process.argv.slice(2));
|
|
43
|
+
const { help, version, verbose, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
43
44
|
if (help) {
|
|
44
45
|
console.log(getHelpText());
|
|
45
46
|
return;
|
|
@@ -48,6 +49,18 @@ async function main() {
|
|
|
48
49
|
console.log(packageVersion);
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
52
|
+
if (usageError) {
|
|
53
|
+
console.error(`tailwind-a11y: ${usageError}`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { palette, spacing, configError } = resolveTheme({
|
|
58
|
+
rootDir: process.cwd(),
|
|
59
|
+
configPath: config ? resolve(process.cwd(), config) : null,
|
|
60
|
+
});
|
|
61
|
+
if (configError) {
|
|
62
|
+
console.warn(`tailwind-a11y: ${configError}`);
|
|
63
|
+
}
|
|
51
64
|
const globPatterns = patterns.length > 0 ? patterns : ["**/*.{jsx,tsx}"];
|
|
52
65
|
const files = await fg(globPatterns, {
|
|
53
66
|
cwd: process.cwd(),
|
|
@@ -64,9 +77,9 @@ async function main() {
|
|
|
64
77
|
try {
|
|
65
78
|
const code = readFileSync(absPath, "utf8");
|
|
66
79
|
const contrastChecks = extractChecks(code, file);
|
|
67
|
-
violations.push(...checkContrast(contrastChecks), ...checkTouchTargets(extractTouchTargetChecks(code, file)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
80
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
68
81
|
if (verbose) {
|
|
69
|
-
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks), ...extractTouchTargetSkips(code, file));
|
|
82
|
+
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
catch (err) {
|
package/dist/cliArgs.d.ts
CHANGED
package/dist/cliArgs.js
CHANGED
|
@@ -6,14 +6,18 @@ Static analysis for Tailwind CSS accessibility violations -- color contrast,
|
|
|
6
6
|
touch target size, and focus indicator removal.
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
|
-
-v, --verbose
|
|
10
|
-
-V, --version
|
|
11
|
-
-h, --help
|
|
9
|
+
-v, --verbose Also report what couldn't be checked, and why
|
|
10
|
+
-V, --version Print the version number
|
|
11
|
+
-h, --help Print this help message
|
|
12
|
+
--config <path> Path to a tailwind.config.js/.cjs to read custom
|
|
13
|
+
theme colors/spacing from (default: auto-detected
|
|
14
|
+
in the current directory)
|
|
12
15
|
|
|
13
16
|
Examples:
|
|
14
17
|
tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
|
|
15
18
|
tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
|
|
16
19
|
tailwind-a11y --verbose Also report skipped/unresolvable cases
|
|
20
|
+
tailwind-a11y --config ./tailwind.config.cjs
|
|
17
21
|
`;
|
|
18
22
|
export function getHelpText() {
|
|
19
23
|
return HELP_TEXT;
|
|
@@ -22,10 +26,34 @@ export function getHelpText() {
|
|
|
22
26
|
// avoids colliding with it, matching a common CLI convention.
|
|
23
27
|
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
|
|
24
28
|
export function parseArgs(argv) {
|
|
29
|
+
let config = null;
|
|
30
|
+
let configError = null;
|
|
31
|
+
const patterns = [];
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const arg = argv[i];
|
|
34
|
+
if (arg !== "--config") {
|
|
35
|
+
if (!FLAGS.has(arg))
|
|
36
|
+
patterns.push(arg);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const value = argv[i + 1];
|
|
40
|
+
// A missing value or one that looks like another flag (starts with "-")
|
|
41
|
+
// must not be silently swallowed as a bogus path -- report a usage error
|
|
42
|
+
// instead of guessing.
|
|
43
|
+
if (value === undefined || value.startsWith("-")) {
|
|
44
|
+
configError = "--config requires a path argument";
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
config = value;
|
|
48
|
+
i++; // consume the value too, so it isn't also treated as a glob pattern
|
|
49
|
+
}
|
|
50
|
+
}
|
|
25
51
|
return {
|
|
26
52
|
help: argv.includes("--help") || argv.includes("-h"),
|
|
27
53
|
version: argv.includes("--version") || argv.includes("-V"),
|
|
28
54
|
verbose: argv.includes("--verbose") || argv.includes("-v"),
|
|
29
|
-
|
|
55
|
+
config,
|
|
56
|
+
configError,
|
|
57
|
+
patterns,
|
|
30
58
|
};
|
|
31
59
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouch
|
|
|
5
5
|
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
|
+
export type { Palette, ColorScale } from "./theme/defaultPalette.js";
|
package/dist/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
|
5
5
|
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
@@ -7,10 +7,10 @@ export interface TouchTargetCheck {
|
|
|
7
7
|
widthPx: number;
|
|
8
8
|
heightPx: number;
|
|
9
9
|
}
|
|
10
|
-
export declare function extractTouchTargetChecks(code: string, filePath: string): TouchTargetCheck[];
|
|
10
|
+
export declare function extractTouchTargetChecks(code: string, filePath: string, spacing?: Record<string, number>): TouchTargetCheck[];
|
|
11
11
|
export interface TouchTargetSkip {
|
|
12
12
|
file: string;
|
|
13
13
|
line: number;
|
|
14
14
|
reason: string;
|
|
15
15
|
}
|
|
16
|
-
export declare function extractTouchTargetSkips(code: string, filePath: string): TouchTargetSkip[];
|
|
16
|
+
export declare function extractTouchTargetSkips(code: string, filePath: string, spacing?: Record<string, number>): TouchTargetSkip[];
|
|
@@ -42,7 +42,7 @@ function isInlineInText(path) {
|
|
|
42
42
|
return false;
|
|
43
43
|
return isMeaningfulText(siblings[index - 1]) || isMeaningfulText(siblings[index + 1]);
|
|
44
44
|
}
|
|
45
|
-
export function extractTouchTargetChecks(code, filePath) {
|
|
45
|
+
export function extractTouchTargetChecks(code, filePath, spacing = spacingScale) {
|
|
46
46
|
const ast = parseJSX(code, filePath);
|
|
47
47
|
if (!ast)
|
|
48
48
|
return [];
|
|
@@ -60,8 +60,8 @@ export function extractTouchTargetChecks(code, filePath) {
|
|
|
60
60
|
const height = lastSizeToken(tokens, "h");
|
|
61
61
|
if (!width || !height)
|
|
62
62
|
return; // either dimension missing/dynamic — skip, don't guess
|
|
63
|
-
const widthPx =
|
|
64
|
-
const heightPx =
|
|
63
|
+
const widthPx = spacing[width.value];
|
|
64
|
+
const heightPx = spacing[height.value];
|
|
65
65
|
if (widthPx === undefined || heightPx === undefined)
|
|
66
66
|
return; // arbitrary/keyword/fraction — skip
|
|
67
67
|
if (isInlineInText(path))
|
|
@@ -85,7 +85,7 @@ export function extractTouchTargetChecks(code, filePath) {
|
|
|
85
85
|
// fraction). Elements with neither w-* nor h-* at all aren't reported —
|
|
86
86
|
// that's the overwhelming majority of interactive elements and would be
|
|
87
87
|
// pure noise, not a meaningful skip.
|
|
88
|
-
export function extractTouchTargetSkips(code, filePath) {
|
|
88
|
+
export function extractTouchTargetSkips(code, filePath, spacing = spacingScale) {
|
|
89
89
|
const ast = parseJSX(code, filePath);
|
|
90
90
|
if (!ast)
|
|
91
91
|
return [];
|
|
@@ -110,11 +110,11 @@ export function extractTouchTargetSkips(code, filePath) {
|
|
|
110
110
|
skips.push({ file: filePath, line, reason: `${found.raw} present but no ${missing} utility set — skipped` });
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
|
-
const widthPx =
|
|
114
|
-
const heightPx =
|
|
113
|
+
const widthPx = spacing[width.value];
|
|
114
|
+
const heightPx = spacing[height.value];
|
|
115
115
|
if (widthPx === undefined || heightPx === undefined) {
|
|
116
116
|
const bad = widthPx === undefined ? width.raw : height.raw;
|
|
117
|
-
skips.push({ file: filePath, line, reason: `${bad} is not in the
|
|
117
|
+
skips.push({ file: filePath, line, reason: `${bad} is not in the resolved spacing scale (arbitrary, keyword, or fraction value) — skipped` });
|
|
118
118
|
}
|
|
119
119
|
},
|
|
120
120
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from "../theme/defaultPalette.js";
|
|
1
2
|
import type { ContrastCheck } from "../parser/extractClasses.js";
|
|
2
3
|
export interface ContrastViolation {
|
|
3
4
|
type: "contrast";
|
|
@@ -11,16 +12,16 @@ export interface ContrastViolation {
|
|
|
11
12
|
suggestion?: string;
|
|
12
13
|
suggestedRatio?: number;
|
|
13
14
|
}
|
|
14
|
-
export declare function resolveColorValue(utilityClass: string): string | null;
|
|
15
|
-
export declare function checkContrast(checks: ContrastCheck[]): ContrastViolation[];
|
|
15
|
+
export declare function resolveColorValue(utilityClass: string, palette?: Palette): string | null;
|
|
16
|
+
export declare function checkContrast(checks: ContrastCheck[], palette?: Palette): ContrastViolation[];
|
|
16
17
|
export interface ContrastFix {
|
|
17
18
|
textClass: string;
|
|
18
19
|
ratio: number;
|
|
19
20
|
}
|
|
20
|
-
export declare function suggestContrastFix(textClass: string, bgClass: string, required: number): ContrastFix | null;
|
|
21
|
+
export declare function suggestContrastFix(textClass: string, bgClass: string, required: number, palette?: Palette): ContrastFix | null;
|
|
21
22
|
export interface ContrastValueSkip {
|
|
22
23
|
file: string;
|
|
23
24
|
line: number;
|
|
24
25
|
reason: string;
|
|
25
26
|
}
|
|
26
|
-
export declare function checkContrastValueSkips(checks: ContrastCheck[]): ContrastValueSkip[];
|
|
27
|
+
export declare function checkContrastValueSkips(checks: ContrastCheck[], palette?: Palette): ContrastValueSkip[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
2
2
|
import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
|
|
3
|
-
export function resolveColorValue(utilityClass) {
|
|
3
|
+
export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
4
4
|
const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
|
|
5
5
|
if (!match)
|
|
6
6
|
return null;
|
|
@@ -17,13 +17,13 @@ export function resolveColorValue(utilityClass) {
|
|
|
17
17
|
const [scale, shade] = token.split("-");
|
|
18
18
|
if (!scale || !shade)
|
|
19
19
|
return null;
|
|
20
|
-
return
|
|
20
|
+
return palette[scale]?.[shade] ?? null; // unknown/custom color — skip
|
|
21
21
|
}
|
|
22
|
-
export function checkContrast(checks) {
|
|
22
|
+
export function checkContrast(checks, palette = defaultPalette) {
|
|
23
23
|
const violations = [];
|
|
24
24
|
for (const check of checks) {
|
|
25
|
-
const textHex = resolveColorValue(check.textColorClass);
|
|
26
|
-
const bgHex = resolveColorValue(check.bgColorClass);
|
|
25
|
+
const textHex = resolveColorValue(check.textColorClass, palette);
|
|
26
|
+
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
27
27
|
if (!textHex || !bgHex)
|
|
28
28
|
continue;
|
|
29
29
|
const textRgb = hexToRgb(textHex);
|
|
@@ -33,7 +33,7 @@ export function checkContrast(checks) {
|
|
|
33
33
|
const ratio = contrastRatio(textRgb, bgRgb);
|
|
34
34
|
const required = requiredRatio("AA", false); // v1: large-text detection deferred
|
|
35
35
|
if (!meetsWCAG(ratio, "AA", false)) {
|
|
36
|
-
const fix = suggestContrastFix(check.textColorClass, check.bgColorClass, required);
|
|
36
|
+
const fix = suggestContrastFix(check.textColorClass, check.bgColorClass, required, palette);
|
|
37
37
|
violations.push({
|
|
38
38
|
type: "contrast",
|
|
39
39
|
file: check.file,
|
|
@@ -58,15 +58,15 @@ const TEXT_SCALE_SHADE_RE = /^text-([a-z]+)-(\d+)$/;
|
|
|
58
58
|
// darker is the fix a human reaches for. The original shade can never win:
|
|
59
59
|
// it's in this same candidate list at distance 0, and this recomputes the
|
|
60
60
|
// identical unrounded ratio comparison that just failed.
|
|
61
|
-
export function suggestContrastFix(textClass, bgClass, required) {
|
|
61
|
+
export function suggestContrastFix(textClass, bgClass, required, palette = defaultPalette) {
|
|
62
62
|
const match = TEXT_SCALE_SHADE_RE.exec(textClass);
|
|
63
63
|
if (!match)
|
|
64
64
|
return null; // text-white, text-[#eee], text-gray-400/50 — no suggestion
|
|
65
65
|
const [, scale, shade] = match;
|
|
66
|
-
const shades =
|
|
66
|
+
const shades = palette[scale];
|
|
67
67
|
if (!shades?.[shade])
|
|
68
68
|
return null; // custom scale, or a decoy like text-opacity-50
|
|
69
|
-
const bgHex = resolveColorValue(bgClass);
|
|
69
|
+
const bgHex = resolveColorValue(bgClass, palette);
|
|
70
70
|
const bgRgb = bgHex ? hexToRgb(bgHex) : null;
|
|
71
71
|
if (!bgRgb)
|
|
72
72
|
return null;
|
|
@@ -90,11 +90,11 @@ export function suggestContrastFix(textClass, bgClass, required) {
|
|
|
90
90
|
// non-hex arbitrary value, opacity shorthand) — surfaced separately from
|
|
91
91
|
// extractContrastSkips' component-boundary case, since this one already has
|
|
92
92
|
// a full text/bg pair and only failed at value resolution.
|
|
93
|
-
export function checkContrastValueSkips(checks) {
|
|
93
|
+
export function checkContrastValueSkips(checks, palette = defaultPalette) {
|
|
94
94
|
const skips = [];
|
|
95
95
|
for (const check of checks) {
|
|
96
|
-
const textHex = resolveColorValue(check.textColorClass);
|
|
97
|
-
const bgHex = resolveColorValue(check.bgColorClass);
|
|
96
|
+
const textHex = resolveColorValue(check.textColorClass, palette);
|
|
97
|
+
const bgHex = resolveColorValue(check.bgColorClass, palette);
|
|
98
98
|
if (textHex && bgHex)
|
|
99
99
|
continue;
|
|
100
100
|
const unresolved = !textHex ? check.textColorClass : check.bgColorClass;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Palette } from "./defaultPalette.js";
|
|
2
|
+
export declare function findTailwindConfig(rootDir: string): string | null;
|
|
3
|
+
export interface RawCustomTheme {
|
|
4
|
+
colors?: Palette;
|
|
5
|
+
spacing?: Record<string, number>;
|
|
6
|
+
}
|
|
7
|
+
export declare function loadCustomTheme(configPath: string): RawCustomTheme | null;
|
|
8
|
+
export declare function mergePalette(base: Palette, extend?: Palette): Palette;
|
|
9
|
+
export declare function mergeSpacing(base: Record<string, number>, extend?: Record<string, number>): Record<string, number>;
|
|
10
|
+
export interface ResolvedTheme {
|
|
11
|
+
palette: Palette;
|
|
12
|
+
spacing: Record<string, number>;
|
|
13
|
+
configError?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveTheme(opts: {
|
|
16
|
+
rootDir: string | null;
|
|
17
|
+
configPath?: string | null;
|
|
18
|
+
}): ResolvedTheme;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { hexToRgb } from "../contrast/luminance.js";
|
|
5
|
+
import { defaultPalette } from "./defaultPalette.js";
|
|
6
|
+
import { spacingScale } from "./spacingScale.js";
|
|
7
|
+
const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs"];
|
|
8
|
+
// v1 only looks in the given directory itself -- no ancestor-directory search.
|
|
9
|
+
// --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
|
|
10
|
+
// explicit escape hatches for projects where this isn't enough. `rootDir` must
|
|
11
|
+
// be an absolute path.
|
|
12
|
+
export function findTailwindConfig(rootDir) {
|
|
13
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
14
|
+
const candidate = join(rootDir, filename);
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const SPACING_RE = /^-?[\d.]+(rem|px)$/;
|
|
21
|
+
function parseSpacingValue(value) {
|
|
22
|
+
if (typeof value !== "string")
|
|
23
|
+
return null;
|
|
24
|
+
const match = SPACING_RE.exec(value);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null; // em/%/vw/bare number/function -- skip, don't guess
|
|
27
|
+
const num = parseFloat(value);
|
|
28
|
+
return match[1] === "rem" ? num * 16 : num; // matches spacingScale.ts's 16px-root assumption
|
|
29
|
+
}
|
|
30
|
+
// Only plain hex-shade objects are accepted (e.g. `brand: { 500: '#3490dc' }`).
|
|
31
|
+
// A flat string color (`brand: '#3490dc'`) or a `DEFAULT` key is skipped
|
|
32
|
+
// entirely -- there's no class syntax ("bg-brand-DEFAULT" isn't real Tailwind)
|
|
33
|
+
// that would ever resolve to it, so partially supporting it would be dead code.
|
|
34
|
+
function parseColorScale(value) {
|
|
35
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
36
|
+
return null;
|
|
37
|
+
const shades = {};
|
|
38
|
+
for (const [shade, shadeValue] of Object.entries(value)) {
|
|
39
|
+
if (shade === "DEFAULT")
|
|
40
|
+
continue; // no "bg-brand-DEFAULT" class syntax exists to resolve it
|
|
41
|
+
if (typeof shadeValue !== "string")
|
|
42
|
+
continue;
|
|
43
|
+
if (hexToRgb(shadeValue) === null)
|
|
44
|
+
continue; // not a hex value -- skip, don't guess
|
|
45
|
+
shades[shade] = shadeValue;
|
|
46
|
+
}
|
|
47
|
+
return Object.keys(shades).length > 0 ? shades : null;
|
|
48
|
+
}
|
|
49
|
+
// Loads a tailwind.config.js/.cjs and extracts only `theme.extend.colors` /
|
|
50
|
+
// `theme.extend.spacing` -- v1 does not read a full `theme.colors`/`theme.spacing`
|
|
51
|
+
// replacement, Tailwind v4's CSS-based `@theme` config, or .mjs/.ts configs (no
|
|
52
|
+
// config-transpiling dependency exists in this package). `configPath` must be
|
|
53
|
+
// an absolute path (require() resolves relative paths against this module's
|
|
54
|
+
// own location, not the caller's cwd).
|
|
55
|
+
//
|
|
56
|
+
// Node's require() cache is busted before loading -- recursively, for the
|
|
57
|
+
// config file *and* everything it required (e.g. a config that factors
|
|
58
|
+
// tokens into a separate `require('./colors.js')`) -- without this, a
|
|
59
|
+
// long-lived process (the VS Code extension host, an editor-integrated
|
|
60
|
+
// ESLint server) would keep serving a stale value forever after the user
|
|
61
|
+
// edits any file the config depends on, not just the config file itself.
|
|
62
|
+
function bustRequireCache(require, mod, seen) {
|
|
63
|
+
if (seen.has(mod.id))
|
|
64
|
+
return;
|
|
65
|
+
seen.add(mod.id);
|
|
66
|
+
for (const child of mod.children)
|
|
67
|
+
bustRequireCache(require, child, seen);
|
|
68
|
+
delete require.cache[mod.id];
|
|
69
|
+
}
|
|
70
|
+
// Returns null only when the file itself couldn't be loaded (missing,
|
|
71
|
+
// syntax error, ERR_REQUIRE_ESM for a "type": "module" project, or a config
|
|
72
|
+
// that throws) -- a config that loads fine but has no theme.extend colors or
|
|
73
|
+
// spacing returns {}, which callers must not treat as an error.
|
|
74
|
+
export function loadCustomTheme(configPath) {
|
|
75
|
+
try {
|
|
76
|
+
const require = createRequire(import.meta.url);
|
|
77
|
+
const resolved = require.resolve(configPath);
|
|
78
|
+
const cached = require.cache[resolved];
|
|
79
|
+
if (cached)
|
|
80
|
+
bustRequireCache(require, cached, new Set());
|
|
81
|
+
const config = require(resolved);
|
|
82
|
+
const extend = config?.theme?.extend ?? {};
|
|
83
|
+
const result = {};
|
|
84
|
+
if (extend.colors && typeof extend.colors === "object") {
|
|
85
|
+
const colors = {};
|
|
86
|
+
for (const [scale, value] of Object.entries(extend.colors)) {
|
|
87
|
+
const shades = parseColorScale(value);
|
|
88
|
+
if (shades)
|
|
89
|
+
colors[scale] = shades;
|
|
90
|
+
}
|
|
91
|
+
if (Object.keys(colors).length > 0)
|
|
92
|
+
result.colors = colors;
|
|
93
|
+
}
|
|
94
|
+
if (extend.spacing && typeof extend.spacing === "object") {
|
|
95
|
+
const spacing = {};
|
|
96
|
+
for (const [token, value] of Object.entries(extend.spacing)) {
|
|
97
|
+
const px = parseSpacingValue(value);
|
|
98
|
+
if (px !== null)
|
|
99
|
+
spacing[token] = px;
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(spacing).length > 0)
|
|
102
|
+
result.spacing = spacing;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// New scale names are added wholesale; extending an existing scale merges
|
|
111
|
+
// shade keys in without dropping the scale's other (default) shades.
|
|
112
|
+
export function mergePalette(base, extend) {
|
|
113
|
+
if (!extend)
|
|
114
|
+
return base;
|
|
115
|
+
const merged = { ...base };
|
|
116
|
+
for (const [scale, shades] of Object.entries(extend)) {
|
|
117
|
+
merged[scale] = { ...merged[scale], ...shades };
|
|
118
|
+
}
|
|
119
|
+
return merged;
|
|
120
|
+
}
|
|
121
|
+
export function mergeSpacing(base, extend) {
|
|
122
|
+
return extend ? { ...base, ...extend } : base;
|
|
123
|
+
}
|
|
124
|
+
// configError is only ever set when a path was *explicitly* provided (CLI
|
|
125
|
+
// --config flag or ESLint settings) and failed to load -- auto-detected
|
|
126
|
+
// absence stays silent, matching the "avoid confusing noise in an unrelated
|
|
127
|
+
// linter run" precedent already in the ESLint plugin's index.ts. `rootDir`
|
|
128
|
+
// and `configPath` (if given) must be absolute paths.
|
|
129
|
+
export function resolveTheme(opts) {
|
|
130
|
+
const explicitPath = opts.configPath ?? null;
|
|
131
|
+
const configPath = explicitPath ?? (opts.rootDir ? findTailwindConfig(opts.rootDir) : null);
|
|
132
|
+
if (!configPath) {
|
|
133
|
+
return { palette: defaultPalette, spacing: spacingScale };
|
|
134
|
+
}
|
|
135
|
+
const custom = loadCustomTheme(configPath);
|
|
136
|
+
if (custom === null) {
|
|
137
|
+
return explicitPath
|
|
138
|
+
? {
|
|
139
|
+
palette: defaultPalette,
|
|
140
|
+
spacing: spacingScale,
|
|
141
|
+
configError: `could not load Tailwind config at ${explicitPath}`,
|
|
142
|
+
}
|
|
143
|
+
: { palette: defaultPalette, spacing: spacingScale };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
palette: mergePalette(defaultPalette, custom.colors),
|
|
147
|
+
spacing: mergeSpacing(spacingScale, custom.spacing),
|
|
148
|
+
};
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal — in Tailwind CSS class combinations before they ship.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|