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 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
@@ -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 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";
@@ -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
- 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})?$/;
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
- const DEGENERATE_BASES = new Set(["outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent"]);
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
- const NON_VISUAL_PATTERN = /^bg-blend-/;
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
- 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.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": {