tailwind-a11y 0.6.0 → 0.8.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 +8 -5
- package/dist/cliArgs.js +4 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/parser/extractClasses.d.ts +1 -0
- package/dist/parser/extractClasses.js +5 -1
- package/dist/rules/checkFocusIndicator.js +38 -4
- package/dist/theme/loadCustomTheme.d.ts +2 -0
- package/dist/theme/loadCustomTheme.js +58 -38
- package/dist/theme/parseThemeCss.d.ts +2 -0
- package/dist/theme/parseThemeCss.js +133 -0
- package/dist/theme/themeValueParsers.d.ts +2 -0
- package/dist/theme/themeValueParsers.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,15 +25,17 @@ npm install --save-dev tailwind-a11y
|
|
|
25
25
|
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
26
26
|
npx tailwind-a11y "src/**/*.tsx" # custom glob
|
|
27
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
|
|
28
|
+
npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
|
|
29
29
|
npx tailwind-a11y --version # print the installed version
|
|
30
30
|
npx tailwind-a11y --help # usage and all options
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Custom
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
Custom theme colors/spacing are read automatically, so colors and spacing outside
|
|
34
|
+
Tailwind's defaults resolve too — not just the built-in palette. Both config formats
|
|
35
|
+
are supported: `theme.extend.colors`/`theme.extend.spacing` in a Tailwind v3
|
|
36
|
+
`tailwind.config.js`/`.cjs`, and `--color-*`/`--spacing-*` custom properties in a
|
|
37
|
+
Tailwind v4 CSS `@theme { ... }` block (auto-detected from common paths like
|
|
38
|
+
`app/globals.css`, or passed via `--config`).
|
|
37
39
|
|
|
38
40
|
```
|
|
39
41
|
src/components/Card.tsx
|
|
@@ -76,6 +78,7 @@ When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
|
76
78
|
|
|
77
79
|
- [`eslint-plugin-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/eslint-plugin-tailwind-a11y) — same checks as ESLint rules
|
|
78
80
|
- [`vscode-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/vscode-tailwind-a11y) — same checks as live editor diagnostics
|
|
81
|
+
- [`github-action-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/github-action-tailwind-a11y) — same checks as inline PR annotations
|
|
79
82
|
|
|
80
83
|
## License
|
|
81
84
|
|
package/dist/cliArgs.js
CHANGED
|
@@ -9,9 +9,10 @@ Options:
|
|
|
9
9
|
-v, --verbose Also report what couldn't be checked, and why
|
|
10
10
|
-V, --version Print the version number
|
|
11
11
|
-h, --help Print this help message
|
|
12
|
-
--config <path> Path to a tailwind.config.js/.cjs
|
|
13
|
-
theme
|
|
14
|
-
|
|
12
|
+
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
13
|
+
@theme file like app/globals.css (v4) to read
|
|
14
|
+
custom theme colors/spacing from (default:
|
|
15
|
+
auto-detected in the current directory)
|
|
15
16
|
|
|
16
17
|
Examples:
|
|
17
18
|
tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
|
package/dist/index.d.ts
CHANGED
|
@@ -5,5 +5,6 @@ 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";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
|
+
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
|
9
10
|
export type { Palette, ColorScale } from "./theme/defaultPalette.js";
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,5 @@ 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";
|
|
8
|
+
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
9
|
+
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
|
@@ -5,6 +5,7 @@ export interface ContrastCheck {
|
|
|
5
5
|
bgColorClass: string;
|
|
6
6
|
bgSource: "self" | "parent";
|
|
7
7
|
}
|
|
8
|
+
export declare const COLOR_TOKEN: RegExp;
|
|
8
9
|
export declare function lastColorToken(className: string, prefix: "text" | "bg"): string | null;
|
|
9
10
|
export declare function extractChecks(code: string, filePath: string): ContrastCheck[];
|
|
10
11
|
export interface ContrastSkip {
|
|
@@ -9,7 +9,11 @@ import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
|
9
9
|
// an extremely common real idiom, more so than the named-scale case, and
|
|
10
10
|
// dropping it here would make the contrast checker's opacity support (see
|
|
11
11
|
// rules/checkContrast.ts) silently inapplicable to the most common case.
|
|
12
|
-
|
|
12
|
+
// Exported for reuse in checkFocusIndicator.ts, which needs the exact same
|
|
13
|
+
// "does this suffix look like a color value" test to detect shadow-{color}/
|
|
14
|
+
// ring-{color} decoys (see that file for why) -- one definition, not a
|
|
15
|
+
// second copy that could silently drift out of sync.
|
|
16
|
+
export const COLOR_TOKEN = /^\[(#[0-9a-fA-F]{3,8})\](\/\d{1,3})?$|^[a-z]+-\d{2,3}(\/\d{1,3})?$|^(white|black|transparent|current|inherit)(\/\d{1,3})?$/;
|
|
13
17
|
// opacity-{N} (e.g. bg-opacity-50, text-opacity-50) matches the same
|
|
14
18
|
// "word-number" shape as a color token but isn't one — without this
|
|
15
19
|
// exclusion it can silently overwrite a real color match via the
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { COLOR_TOKEN } from "../parser/extractClasses.js";
|
|
1
2
|
const REMOVAL_BASE = "outline-none";
|
|
2
3
|
// Utilities that match the "replacement" shape but are semantically no-ops —
|
|
3
4
|
// the same failure mode as bg-opacity-50 masking a real color match: a
|
|
4
5
|
// same-prefix decoy that would silently hide a real violation if we only
|
|
5
|
-
// checked the prefix.
|
|
6
|
-
|
|
6
|
+
// checked the prefix. border-none/border-hidden only set border-style, not
|
|
7
|
+
// border-width -- verified against a real Tailwind v4 build that preflight
|
|
8
|
+
// resets every element to `border: 0 solid`, so border-width stays 0
|
|
9
|
+
// regardless of style and no border is ever drawn, same failure mode as
|
|
10
|
+
// border-0.
|
|
11
|
+
const DEGENERATE_BASES = new Set([
|
|
12
|
+
"outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent",
|
|
13
|
+
"border-none", "border-hidden",
|
|
14
|
+
]);
|
|
7
15
|
// Modifier-only utilities (opacity/offset/inset) don't set a concrete value
|
|
8
16
|
// on their own — e.g. bg-opacity-50 with no bg-* color, or ring-offset-4
|
|
9
17
|
// with no ring-* width, renders nothing visible by itself. Same failure
|
|
@@ -31,8 +39,32 @@ const NON_VISUAL_BASES = new Set([
|
|
|
31
39
|
]);
|
|
32
40
|
// bg-blend-{mode} (e.g. bg-blend-multiply) sets a blend mode, not a color --
|
|
33
41
|
// suffix-varying like MODIFIER_ONLY above, so pattern-matched instead of
|
|
34
|
-
// enumerated.
|
|
35
|
-
|
|
42
|
+
// enumerated. border-spacing-*/-x-*/-y-* sets the CSS border-spacing
|
|
43
|
+
// property, which only affects the gap between <table> cells -- verified
|
|
44
|
+
// against a real Tailwind v4 build that it has zero visual effect on any
|
|
45
|
+
// non-table element (the only elements this check ever fires on), even
|
|
46
|
+
// with a real numeric value applied.
|
|
47
|
+
const NON_VISUAL_PATTERN = /^bg-blend-|^border-spacing(-[xy])?-/;
|
|
48
|
+
// shadow-{color}/ring-{color} alone (e.g. shadow-red-500, ring-blue-400/50,
|
|
49
|
+
// an arbitrary hex) only sets the --tw-shadow-color/--tw-ring-color CSS
|
|
50
|
+
// variable -- the actual box-shadow property is set by a separate *size*
|
|
51
|
+
// utility (shadow-lg, ring-2, etc.) that references that variable. Verified
|
|
52
|
+
// against a real Tailwind v4 build: shadow-red-500 alone computes to
|
|
53
|
+
// `box-shadow: none`; shadow-lg shadow-red-500 together produce a real,
|
|
54
|
+
// colored shadow. Same underlying mechanism as MODIFIER_ONLY's opacity/
|
|
55
|
+
// offset cases above, but shaped like a color token rather than a fixed
|
|
56
|
+
// suffix, so it reuses COLOR_TOKEN (the exact same "is this a color value"
|
|
57
|
+
// test extractClasses.ts uses) instead of a third, drifting definition of
|
|
58
|
+
// what a color looks like.
|
|
59
|
+
function isColorOnlyShadowOrRing(base) {
|
|
60
|
+
const shadowMatch = /^shadow-(.+)$/.exec(base);
|
|
61
|
+
if (shadowMatch && COLOR_TOKEN.test(shadowMatch[1]))
|
|
62
|
+
return true;
|
|
63
|
+
const ringMatch = /^ring-(.+)$/.exec(base);
|
|
64
|
+
if (ringMatch && COLOR_TOKEN.test(ringMatch[1]))
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
36
68
|
function baseUtility(raw) {
|
|
37
69
|
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
38
70
|
}
|
|
@@ -42,6 +74,8 @@ function isReplacement(raw) {
|
|
|
42
74
|
return false;
|
|
43
75
|
if (NON_VISUAL_BASES.has(base) || NON_VISUAL_PATTERN.test(base))
|
|
44
76
|
return false;
|
|
77
|
+
if (isColorOnlyShadowOrRing(base))
|
|
78
|
+
return false;
|
|
45
79
|
return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
|
|
46
80
|
}
|
|
47
81
|
export function checkFocusIndicators(checks) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Palette } from "./defaultPalette.js";
|
|
2
2
|
export declare function findTailwindConfig(rootDir: string): string | null;
|
|
3
|
+
export declare function findTailwindThemeCss(rootDir: string): string | null;
|
|
3
4
|
export interface RawCustomTheme {
|
|
4
5
|
colors?: Palette;
|
|
5
6
|
spacing?: Record<string, number>;
|
|
6
7
|
}
|
|
7
8
|
export declare function loadCustomTheme(configPath: string): RawCustomTheme | null;
|
|
9
|
+
export declare function loadThemeFromCssFile(cssPath: string): RawCustomTheme | null;
|
|
8
10
|
export declare function mergePalette(base: Palette, extend?: Palette): Palette;
|
|
9
11
|
export declare function mergeSpacing(base: Record<string, number>, extend?: Record<string, number>): Record<string, number>;
|
|
10
12
|
export interface ResolvedTheme {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { hexToRgb } from "../contrast/luminance.js";
|
|
5
4
|
import { defaultPalette } from "./defaultPalette.js";
|
|
6
5
|
import { spacingScale } from "./spacingScale.js";
|
|
6
|
+
import { parseColorScale, parseSpacingValue } from "./themeValueParsers.js";
|
|
7
|
+
import { parseThemeCss } from "./parseThemeCss.js";
|
|
7
8
|
const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs"];
|
|
8
9
|
// v1 only looks in the given directory itself -- no ancestor-directory search.
|
|
9
10
|
// --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
|
|
@@ -17,41 +18,38 @@ export function findTailwindConfig(rootDir) {
|
|
|
17
18
|
}
|
|
18
19
|
return null;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
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;
|
|
21
|
+
// Checked in priority order, in rootDir only (no recursive walk) -- v4 has no
|
|
22
|
+
// single conventional filename the way v3 has tailwind.config.js, so this is
|
|
23
|
+
// a heuristic covering common Next.js App Router / Pages Router / Vite React
|
|
24
|
+
// conventions, most-specific first. `globals.css` is checked last since it's
|
|
25
|
+
// the most generic name and the most likely to false-positive-match a file
|
|
26
|
+
// that isn't actually the Tailwind entry point. --config (CLI) /
|
|
27
|
+
// settings["tailwind-a11y"].configPath (ESLint) / INPUT_CONFIG (Action)
|
|
28
|
+
// remain the escape hatch for anything else.
|
|
29
|
+
const CSS_THEME_CANDIDATES = [
|
|
30
|
+
"app/globals.css",
|
|
31
|
+
"src/app/globals.css",
|
|
32
|
+
"styles/globals.css",
|
|
33
|
+
"src/styles/globals.css",
|
|
34
|
+
"src/index.css",
|
|
35
|
+
"globals.css",
|
|
36
|
+
];
|
|
37
|
+
export function findTailwindThemeCss(rootDir) {
|
|
38
|
+
for (const rel of CSS_THEME_CANDIDATES) {
|
|
39
|
+
const candidate = join(rootDir, rel);
|
|
40
|
+
if (existsSync(candidate))
|
|
41
|
+
return candidate;
|
|
46
42
|
}
|
|
47
|
-
return
|
|
43
|
+
return null;
|
|
48
44
|
}
|
|
49
|
-
// Loads a tailwind.config.js/.cjs and extracts only
|
|
50
|
-
// `theme.extend.spacing` -- v1 does not read a full
|
|
51
|
-
//
|
|
52
|
-
// config-transpiling dependency exists in this package).
|
|
53
|
-
//
|
|
54
|
-
//
|
|
45
|
+
// Loads a Tailwind v3-style tailwind.config.js/.cjs and extracts only
|
|
46
|
+
// `theme.extend.colors`/`theme.extend.spacing` -- v1 does not read a full
|
|
47
|
+
// `theme.colors`/`theme.spacing` replacement, or .mjs/.ts configs (no
|
|
48
|
+
// config-transpiling dependency exists in this package). Tailwind v4's
|
|
49
|
+
// CSS-based `@theme` config is a separate format entirely, handled by
|
|
50
|
+
// loadThemeFromCssFile()/parseThemeCss() below, not by this function.
|
|
51
|
+
// `configPath` must be an absolute path (require() resolves relative paths
|
|
52
|
+
// against this module's own location, not the caller's cwd).
|
|
55
53
|
//
|
|
56
54
|
// Node's require() cache is busted before loading -- recursively, for the
|
|
57
55
|
// config file *and* everything it required (e.g. a config that factors
|
|
@@ -114,6 +112,19 @@ export function loadCustomTheme(configPath) {
|
|
|
114
112
|
return null;
|
|
115
113
|
}
|
|
116
114
|
}
|
|
115
|
+
// Loads a Tailwind v4 CSS file and extracts @theme colors/spacing via
|
|
116
|
+
// parseThemeCss(). Returns null only when the file itself couldn't be read
|
|
117
|
+
// (missing, permission error) -- a file that reads fine but has no @theme
|
|
118
|
+
// block (or nothing recognized inside one) returns {}, same contract as
|
|
119
|
+
// loadCustomTheme. `cssPath` must be an absolute path.
|
|
120
|
+
export function loadThemeFromCssFile(cssPath) {
|
|
121
|
+
try {
|
|
122
|
+
return parseThemeCss(readFileSync(cssPath, "utf8"));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
117
128
|
// New scale names are added wholesale; extending an existing scale merges
|
|
118
129
|
// shade keys in without dropping the scale's other (default) shades.
|
|
119
130
|
export function mergePalette(base, extend) {
|
|
@@ -132,14 +143,23 @@ export function mergeSpacing(base, extend) {
|
|
|
132
143
|
// --config flag or ESLint settings) and failed to load -- auto-detected
|
|
133
144
|
// absence stays silent, matching the "avoid confusing noise in an unrelated
|
|
134
145
|
// linter run" precedent already in the ESLint plugin's index.ts. `rootDir`
|
|
135
|
-
// and `configPath` (if given) must be absolute paths.
|
|
146
|
+
// and `configPath` (if given) must be absolute paths. `configPath` may point
|
|
147
|
+
// at either a .js/.cjs config or a .css file with an @theme block --
|
|
148
|
+
// dispatched by extension below.
|
|
149
|
+
//
|
|
150
|
+
// Auto-detection tries the JS config first, CSS second: an existing project
|
|
151
|
+
// with a tailwind.config.js gets zero change in resolved output even if it
|
|
152
|
+
// also happens to have an unrelated @theme block (e.g. mid v3-to-v4
|
|
153
|
+
// migration, or a leftover v3 config alongside a partially-adopted v4 CSS
|
|
154
|
+
// file).
|
|
136
155
|
export function resolveTheme(opts) {
|
|
137
156
|
const explicitPath = opts.configPath ?? null;
|
|
138
|
-
const configPath = explicitPath ??
|
|
157
|
+
const configPath = explicitPath ??
|
|
158
|
+
(opts.rootDir ? (findTailwindConfig(opts.rootDir) ?? findTailwindThemeCss(opts.rootDir)) : null);
|
|
139
159
|
if (!configPath) {
|
|
140
160
|
return { palette: defaultPalette, spacing: spacingScale };
|
|
141
161
|
}
|
|
142
|
-
const custom = loadCustomTheme(configPath);
|
|
162
|
+
const custom = configPath.endsWith(".css") ? loadThemeFromCssFile(configPath) : loadCustomTheme(configPath);
|
|
143
163
|
if (custom === null) {
|
|
144
164
|
return explicitPath
|
|
145
165
|
? {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { parseColorScale, parseSpacingValue } from "./themeValueParsers.js";
|
|
2
|
+
// Run BEFORE comment-stripping/block-extraction so quoted string content
|
|
3
|
+
// (e.g. a `content: "@theme { --color-brand-500: #3490dc; }"` value) can
|
|
4
|
+
// never be mistaken for a real @theme block or accidentally throw off brace
|
|
5
|
+
// counting -- without this, any valid CSS file containing that substring
|
|
6
|
+
// for unrelated reasons (docs/marketing copy showcasing Tailwind v4 syntax,
|
|
7
|
+
// for instance) would silently inject spurious palette/spacing entries.
|
|
8
|
+
// Replaces each matched string (quotes included) with same-length spaces,
|
|
9
|
+
// so it can't itself contain unmasked quotes/braces/`@theme` text, while
|
|
10
|
+
// keeping offsets stable. An unterminated string (malformed CSS) just
|
|
11
|
+
// doesn't match and is left as-is -- fail safe, don't guess.
|
|
12
|
+
function maskStringLiterals(css) {
|
|
13
|
+
return css.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, (m) => " ".repeat(m.length));
|
|
14
|
+
}
|
|
15
|
+
function stripComments(css) {
|
|
16
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
17
|
+
}
|
|
18
|
+
// Matches `@theme {`, `@theme inline {`, `@theme static {`, and any other
|
|
19
|
+
// modifier keyword Tailwind might add between `@theme` and the opening
|
|
20
|
+
// brace -- the declaration syntax inside is identical regardless of
|
|
21
|
+
// modifier, so no need to enumerate them. Brace-depth-counted (not a naive
|
|
22
|
+
// first-`}` match) so an accidental nested `{` in a value can't truncate the
|
|
23
|
+
// block early.
|
|
24
|
+
//
|
|
25
|
+
// An unclosed `@theme{` (malformed CSS -- missing a `}` somewhere) aborts
|
|
26
|
+
// extraction for the rest of the file, not just that one block: brace
|
|
27
|
+
// counting has no way to tell "a later, well-formed @theme block" apart
|
|
28
|
+
// from "more content nested inside the still-open first block," so any
|
|
29
|
+
// `{`/`}` pairs after the missing close (including a second, otherwise-valid
|
|
30
|
+
// @theme block) get consumed as if they belonged to the first. The safe,
|
|
31
|
+
// honest result is {} for the whole file rather than a guess at which
|
|
32
|
+
// blocks were "real" -- same "skip, don't guess" precedent as everywhere
|
|
33
|
+
// else in this parser, just at file granularity instead of block
|
|
34
|
+
// granularity when recovery isn't possible.
|
|
35
|
+
function extractThemeBlocks(css) {
|
|
36
|
+
const blocks = [];
|
|
37
|
+
const OPEN_RE = /@theme\b[^{]*\{/g;
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = OPEN_RE.exec(css))) {
|
|
40
|
+
const start = match.index + match[0].length;
|
|
41
|
+
let depth = 1;
|
|
42
|
+
let i = start;
|
|
43
|
+
for (; i < css.length && depth > 0; i++) {
|
|
44
|
+
if (css[i] === "{")
|
|
45
|
+
depth++;
|
|
46
|
+
else if (css[i] === "}")
|
|
47
|
+
depth--;
|
|
48
|
+
}
|
|
49
|
+
if (depth !== 0)
|
|
50
|
+
break;
|
|
51
|
+
blocks.push(css.slice(start, i - 1));
|
|
52
|
+
OPEN_RE.lastIndex = i;
|
|
53
|
+
}
|
|
54
|
+
return blocks;
|
|
55
|
+
}
|
|
56
|
+
// Split on `;` rather than a single greedy declaration regex, so a missing
|
|
57
|
+
// trailing semicolon before `}` (valid CSS) doesn't drop the last
|
|
58
|
+
// declaration.
|
|
59
|
+
function extractDeclarations(block) {
|
|
60
|
+
const decls = [];
|
|
61
|
+
for (const raw of block.split(";")) {
|
|
62
|
+
const trimmed = raw.trim();
|
|
63
|
+
if (!trimmed)
|
|
64
|
+
continue;
|
|
65
|
+
const colon = trimmed.indexOf(":");
|
|
66
|
+
if (colon === -1)
|
|
67
|
+
continue;
|
|
68
|
+
const prop = trimmed.slice(0, colon).trim();
|
|
69
|
+
if (!prop.startsWith("--"))
|
|
70
|
+
continue;
|
|
71
|
+
decls.push([prop, trimmed.slice(colon + 1).trim()]);
|
|
72
|
+
}
|
|
73
|
+
return decls;
|
|
74
|
+
}
|
|
75
|
+
// Greedy .+ anchored on the *last* -\d+, so multi-hyphen scale names
|
|
76
|
+
// (--color-hot-pink-500) resolve to scale "hot-pink", shade "500".
|
|
77
|
+
const COLOR_PROP_RE = /^--color-(.+)-(\d+)$/;
|
|
78
|
+
const SPACING_PROP_RE = /^--spacing-([\w-]+)$/;
|
|
79
|
+
// Reads Tailwind v4's CSS-first `@theme { ... }` config -- extracts only
|
|
80
|
+
// --color-{name}-{shade} and --spacing-{token} custom properties, running
|
|
81
|
+
// each value through the exact same hex/rem-px acceptance rules as the JS
|
|
82
|
+
// tailwind.config.js path (parseColorScale/parseSpacingValue from
|
|
83
|
+
// themeValueParsers.ts), so no validation logic is duplicated between the
|
|
84
|
+
// two config formats. A bare `--color-brand: #3490dc` (no shade suffix) is
|
|
85
|
+
// skipped -- same "no class syntax to resolve it to" reasoning as the JS
|
|
86
|
+
// path's flat-string-color skip. A bare `--spacing: 0.25rem` (Tailwind v4's
|
|
87
|
+
// global spacing *multiplier*, which scales all arbitrary spacing
|
|
88
|
+
// utilities) is also skipped -- spacingScale.ts is a static named-token to
|
|
89
|
+
// px map, not a multiplier system; supporting the multiplier correctly
|
|
90
|
+
// would mean reimplementing derived-value math, a different feature.
|
|
91
|
+
// `@import`ed files are never followed -- only @theme blocks physically
|
|
92
|
+
// present in the given CSS text are read.
|
|
93
|
+
//
|
|
94
|
+
// Never throws and never returns null -- unlike loadCustomTheme's
|
|
95
|
+
// require()-based loading, this is tolerant scanning, not execution, so
|
|
96
|
+
// there's no "syntax error" concept here. Worst case (no @theme block, or
|
|
97
|
+
// nothing recognized inside one) returns {}, the same "loaded fine but
|
|
98
|
+
// nothing to extend" contract loadCustomTheme uses.
|
|
99
|
+
export function parseThemeCss(cssText) {
|
|
100
|
+
const colorBuckets = {};
|
|
101
|
+
const spacingRaw = {};
|
|
102
|
+
for (const block of extractThemeBlocks(stripComments(maskStringLiterals(cssText)))) {
|
|
103
|
+
for (const [prop, value] of extractDeclarations(block)) {
|
|
104
|
+
const colorMatch = COLOR_PROP_RE.exec(prop);
|
|
105
|
+
if (colorMatch) {
|
|
106
|
+
const [, scale, shade] = colorMatch;
|
|
107
|
+
(colorBuckets[scale] ??= {})[shade] = value;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const spacingMatch = SPACING_PROP_RE.exec(prop);
|
|
111
|
+
if (spacingMatch)
|
|
112
|
+
spacingRaw[spacingMatch[1]] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const result = {};
|
|
116
|
+
const colors = {};
|
|
117
|
+
for (const [scale, shades] of Object.entries(colorBuckets)) {
|
|
118
|
+
const parsed = parseColorScale(shades);
|
|
119
|
+
if (parsed)
|
|
120
|
+
colors[scale] = parsed;
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(colors).length > 0)
|
|
123
|
+
result.colors = colors;
|
|
124
|
+
const spacing = {};
|
|
125
|
+
for (const [token, value] of Object.entries(spacingRaw)) {
|
|
126
|
+
const px = parseSpacingValue(value);
|
|
127
|
+
if (px !== null)
|
|
128
|
+
spacing[token] = px;
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(spacing).length > 0)
|
|
131
|
+
result.spacing = spacing;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { hexToRgb } from "../contrast/luminance.js";
|
|
2
|
+
const SPACING_RE = /^-?[\d.]+(rem|px)$/;
|
|
3
|
+
export function parseSpacingValue(value) {
|
|
4
|
+
if (typeof value !== "string")
|
|
5
|
+
return null;
|
|
6
|
+
const match = SPACING_RE.exec(value);
|
|
7
|
+
if (!match)
|
|
8
|
+
return null; // em/%/vw/bare number/function -- skip, don't guess
|
|
9
|
+
const num = parseFloat(value);
|
|
10
|
+
return match[1] === "rem" ? num * 16 : num; // matches spacingScale.ts's 16px-root assumption
|
|
11
|
+
}
|
|
12
|
+
// Only plain hex-shade objects are accepted (e.g. `brand: { 500: '#3490dc' }`).
|
|
13
|
+
// A flat string color (`brand: '#3490dc'`) or a `DEFAULT` key is skipped
|
|
14
|
+
// entirely -- there's no class syntax ("bg-brand-DEFAULT" isn't real Tailwind)
|
|
15
|
+
// that would ever resolve to it, so partially supporting it would be dead code.
|
|
16
|
+
export function parseColorScale(value) {
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
18
|
+
return null;
|
|
19
|
+
const shades = {};
|
|
20
|
+
for (const [shade, shadeValue] of Object.entries(value)) {
|
|
21
|
+
if (shade === "DEFAULT")
|
|
22
|
+
continue; // no "bg-brand-DEFAULT" class syntax exists to resolve it
|
|
23
|
+
if (typeof shadeValue !== "string")
|
|
24
|
+
continue;
|
|
25
|
+
if (hexToRgb(shadeValue) === null)
|
|
26
|
+
continue; // not a hex value -- skip, don't guess
|
|
27
|
+
shades[shade] = shadeValue;
|
|
28
|
+
}
|
|
29
|
+
return Object.keys(shades).length > 0 ? shades : null;
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|