tailwind-a11y 0.3.0 → 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 CHANGED
@@ -22,13 +22,19 @@ npm install --save-dev tailwind-a11y
22
22
  ## Usage
23
23
 
24
24
  ```bash
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 --version # print the installed version
29
- npx tailwind-a11y --help # usage and all options
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
- - Custom theme colors/spacing not in Tailwind's default scales
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
@@ -2,6 +2,8 @@ export interface ParsedArgs {
2
2
  help: boolean;
3
3
  version: boolean;
4
4
  verbose: boolean;
5
+ config: string | null;
6
+ configError: string | null;
5
7
  patterns: string[];
6
8
  }
7
9
  export declare function getHelpText(): string;
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 Also report what couldn't be checked, and why
10
- -V, --version Print the version number
11
- -h, --help Print this help message
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
- patterns: argv.filter((a) => !FLAGS.has(a)),
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[];
@@ -17,7 +17,32 @@ function lastSizeToken(tokens, prefix) {
17
17
  }
18
18
  return found;
19
19
  }
20
- export function extractTouchTargetChecks(code, filePath) {
20
+ function isMeaningfulText(node) {
21
+ // Pure JSX-formatting whitespace (indentation/newlines between elements)
22
+ // doesn't count as text.
23
+ return !!node && t.isJSXText(node) && node.value.trim().length > 0;
24
+ }
25
+ // WCAG 2.5.8's "Inline" exception: a target inside a sentence or block of
26
+ // text is exempt from the minimum size, since its size is constrained by
27
+ // surrounding text flow rather than a deliberate layout choice. Checked via
28
+ // the element's *immediate* siblings only (not "any text anywhere in the
29
+ // parent") — a parent-wide check would exempt every sibling in something
30
+ // like `<p>Choose: <button/><button/></p>` just because the first button
31
+ // happens to sit next to text, even though the second one doesn't. That's
32
+ // the same "shape, not meaning" failure mode as the bg-opacity-50/ring-0
33
+ // false negatives documented in CLAUDE.md, just at the sibling level instead
34
+ // of the token level.
35
+ function isInlineInText(path) {
36
+ const parentNode = path.parentPath?.node;
37
+ if (!parentNode || (!t.isJSXElement(parentNode) && !t.isJSXFragment(parentNode)))
38
+ return false;
39
+ const siblings = parentNode.children;
40
+ const index = siblings.indexOf(path.node);
41
+ if (index === -1)
42
+ return false;
43
+ return isMeaningfulText(siblings[index - 1]) || isMeaningfulText(siblings[index + 1]);
44
+ }
45
+ export function extractTouchTargetChecks(code, filePath, spacing = spacingScale) {
21
46
  const ast = parseJSX(code, filePath);
22
47
  if (!ast)
23
48
  return [];
@@ -35,10 +60,12 @@ export function extractTouchTargetChecks(code, filePath) {
35
60
  const height = lastSizeToken(tokens, "h");
36
61
  if (!width || !height)
37
62
  return; // either dimension missing/dynamic — skip, don't guess
38
- const widthPx = spacingScale[width.value];
39
- const heightPx = spacingScale[height.value];
63
+ const widthPx = spacing[width.value];
64
+ const heightPx = spacing[height.value];
40
65
  if (widthPx === undefined || heightPx === undefined)
41
66
  return; // arbitrary/keyword/fraction — skip
67
+ if (isInlineInText(path))
68
+ return; // WCAG 2.5.8 inline exception — exempt, not a violation
42
69
  checks.push({
43
70
  file: filePath,
44
71
  line: opening.loc?.start.line ?? 0,
@@ -58,7 +85,7 @@ export function extractTouchTargetChecks(code, filePath) {
58
85
  // fraction). Elements with neither w-* nor h-* at all aren't reported —
59
86
  // that's the overwhelming majority of interactive elements and would be
60
87
  // pure noise, not a meaningful skip.
61
- export function extractTouchTargetSkips(code, filePath) {
88
+ export function extractTouchTargetSkips(code, filePath, spacing = spacingScale) {
62
89
  const ast = parseJSX(code, filePath);
63
90
  if (!ast)
64
91
  return [];
@@ -83,11 +110,11 @@ export function extractTouchTargetSkips(code, filePath) {
83
110
  skips.push({ file: filePath, line, reason: `${found.raw} present but no ${missing} utility set — skipped` });
84
111
  return;
85
112
  }
86
- const widthPx = spacingScale[width.value];
87
- const heightPx = spacingScale[height.value];
113
+ const widthPx = spacing[width.value];
114
+ const heightPx = spacing[height.value];
88
115
  if (widthPx === undefined || heightPx === undefined) {
89
116
  const bad = widthPx === undefined ? width.raw : height.raw;
90
- skips.push({ file: filePath, line, reason: `${bad} is not in the default spacing scale (arbitrary, keyword, or fraction value) — skipped` });
117
+ skips.push({ file: filePath, line, reason: `${bad} is not in the resolved spacing scale (arbitrary, keyword, or fraction value) — skipped` });
91
118
  }
92
119
  },
93
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 defaultPalette[scale]?.[shade] ?? null; // unknown/custom color — skip
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 = defaultPalette[scale];
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.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": {