tailwind-a11y 0.6.0 → 0.7.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 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 tailwind.config instead of auto-detecting
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 `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.
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
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 to read custom
13
- theme colors/spacing from (default: auto-detected
14
- in the current directory)
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";
@@ -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
- 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;
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 Object.keys(shades).length > 0 ? shades : null;
43
+ return null;
48
44
  }
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).
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 ?? (opts.rootDir ? findTailwindConfig(opts.rootDir) : null);
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,2 @@
1
+ import type { RawCustomTheme } from "./loadCustomTheme.js";
2
+ export declare function parseThemeCss(cssText: string): RawCustomTheme;
@@ -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,2 @@
1
+ export declare function parseSpacingValue(value: unknown): number | null;
2
+ export declare function parseColorScale(value: unknown): Record<string, string> | null;
@@ -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.6.0",
3
+ "version": "0.7.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": {