Skip to content

Commit 81fa830

Browse files
Martin Rodriguez HarispeMartin Rodriguez Harispe
authored andcommitted
feat: add configurable accessibility presets
Add accessibility option with three presets: - minimal (default): 15 curated high-impact rules - recommended: matches jsx-a11y/recommended preset (31 rules) - strict: matches jsx-a11y/strict preset (33 rules, all as errors) Can be disabled with `accessibility: false`.
1 parent 7b885c0 commit 81fa830

6 files changed

Lines changed: 292 additions & 22 deletions

File tree

packages/react-doctor/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
import path from "node:path";
22
import { performance } from "node:perf_hooks";
3-
import type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult } from "./types.js";
3+
import type {
4+
AccessibilityPreset,
5+
Diagnostic,
6+
DiffInfo,
7+
ProjectInfo,
8+
ReactDoctorConfig,
9+
ScoreResult,
10+
} from "./types.js";
411
import { calculateScore } from "./utils/calculate-score.js";
512
import { combineDiagnostics, computeJsxIncludePaths } from "./utils/combine-diagnostics.js";
613
import { discoverProject } from "./utils/discover-project.js";
714
import { loadConfig } from "./utils/load-config.js";
815
import { runKnip } from "./utils/run-knip.js";
916
import { runOxlint } from "./utils/run-oxlint.js";
1017

11-
export type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult };
18+
export type {
19+
AccessibilityPreset,
20+
Diagnostic,
21+
DiffInfo,
22+
ProjectInfo,
23+
ReactDoctorConfig,
24+
ScoreResult,
25+
};
1226
export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js";
1327

1428
export interface DiagnoseOptions {
1529
lint?: boolean;
1630
deadCode?: boolean;
1731
includePaths?: string[];
32+
accessibility?: AccessibilityPreset | false;
1833
}
1934

2035
export interface DiagnoseResult {
@@ -38,6 +53,7 @@ export const diagnose = async (
3853

3954
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
4055
const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
56+
const effectiveAccessibility = options.accessibility ?? userConfig?.accessibility ?? "minimal";
4157

4258
if (!projectInfo.reactVersion) {
4359
throw new Error("No React dependency found in package.json");
@@ -54,6 +70,7 @@ export const diagnose = async (
5470
projectInfo.framework,
5571
projectInfo.hasReactCompiler,
5672
jsxIncludePaths,
73+
effectiveAccessibility,
5774
).catch((error: unknown) => {
5875
console.error("Lint failed:", error);
5976
return emptyDiagnostics;

packages/react-doctor/src/oxlint-config.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,70 @@
11
import { createRequire } from "node:module";
2-
import type { Framework } from "./types.js";
2+
import type { AccessibilityPreset, Framework } from "./types.js";
33

44
const esmRequire = createRequire(import.meta.url);
55

6+
// Minimal preset: The original curated set of high-impact rules
7+
const A11Y_RULES_MINIMAL: Record<string, string> = {
8+
"jsx-a11y/alt-text": "error",
9+
"jsx-a11y/anchor-is-valid": "warn",
10+
"jsx-a11y/click-events-have-key-events": "warn",
11+
"jsx-a11y/no-static-element-interactions": "warn",
12+
"jsx-a11y/no-noninteractive-element-interactions": "warn",
13+
"jsx-a11y/role-has-required-aria-props": "error",
14+
"jsx-a11y/no-autofocus": "warn",
15+
"jsx-a11y/heading-has-content": "warn",
16+
"jsx-a11y/html-has-lang": "warn",
17+
"jsx-a11y/no-redundant-roles": "warn",
18+
"jsx-a11y/scope": "warn",
19+
"jsx-a11y/tabindex-no-positive": "warn",
20+
"jsx-a11y/label-has-associated-control": "warn",
21+
"jsx-a11y/no-distracting-elements": "error",
22+
"jsx-a11y/iframe-has-title": "warn",
23+
};
24+
25+
// Recommended preset: All jsx-a11y recommended rules
26+
const A11Y_RULES_RECOMMENDED: Record<string, string> = {
27+
...A11Y_RULES_MINIMAL,
28+
"jsx-a11y/anchor-has-content": "warn",
29+
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
30+
"jsx-a11y/aria-props": "warn",
31+
"jsx-a11y/aria-proptypes": "warn",
32+
"jsx-a11y/aria-role": "warn",
33+
"jsx-a11y/aria-unsupported-elements": "warn",
34+
"jsx-a11y/autocomplete-valid": "warn",
35+
"jsx-a11y/img-redundant-alt": "warn",
36+
"jsx-a11y/interactive-supports-focus": "warn",
37+
"jsx-a11y/media-has-caption": "warn",
38+
"jsx-a11y/mouse-events-have-key-events": "warn",
39+
"jsx-a11y/no-access-key": "warn",
40+
"jsx-a11y/no-interactive-element-to-noninteractive-role": "warn",
41+
"jsx-a11y/no-noninteractive-element-to-interactive-role": "warn",
42+
"jsx-a11y/no-noninteractive-tabindex": "warn",
43+
"jsx-a11y/role-supports-aria-props": "warn",
44+
};
45+
46+
// Strict preset: All recommended rules plus strict-only rules, with errors instead of warnings
47+
const A11Y_RULES_STRICT: Record<string, string> = {
48+
...Object.fromEntries(Object.entries(A11Y_RULES_RECOMMENDED).map(([rule]) => [rule, "error"])),
49+
"jsx-a11y/anchor-ambiguous-text": "error",
50+
"jsx-a11y/control-has-associated-label": "error",
51+
};
52+
53+
const getAccessibilityRules = (preset: AccessibilityPreset | false): Record<string, string> => {
54+
switch (preset) {
55+
case false:
56+
return {};
57+
case "minimal":
58+
return A11Y_RULES_MINIMAL;
59+
case "recommended":
60+
return A11Y_RULES_RECOMMENDED;
61+
case "strict":
62+
return A11Y_RULES_STRICT;
63+
default:
64+
return A11Y_RULES_MINIMAL;
65+
}
66+
};
67+
668
const NEXTJS_RULES: Record<string, string> = {
769
"react-doctor/nextjs-no-img-element": "warn",
870
"react-doctor/nextjs-async-client-component": "error",
@@ -56,12 +118,14 @@ interface OxlintConfigOptions {
56118
pluginPath: string;
57119
framework: Framework;
58120
hasReactCompiler: boolean;
121+
accessibilityPreset: AccessibilityPreset | false;
59122
}
60123

61124
export const createOxlintConfig = ({
62125
pluginPath,
63126
framework,
64127
hasReactCompiler,
128+
accessibilityPreset,
65129
}: OxlintConfigOptions) => ({
66130
categories: {
67131
correctness: "off",
@@ -72,7 +136,11 @@ export const createOxlintConfig = ({
72136
style: "off",
73137
nursery: "off",
74138
},
75-
plugins: ["react", "jsx-a11y", ...(hasReactCompiler ? [] : ["react-perf"])],
139+
plugins: [
140+
"react",
141+
...(accessibilityPreset !== false ? ["jsx-a11y"] : []),
142+
...(hasReactCompiler ? [] : ["react-perf"]),
143+
],
76144
jsPlugins: [
77145
...(hasReactCompiler
78146
? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }]
@@ -93,21 +161,7 @@ export const createOxlintConfig = ({
93161
"react/require-render-return": "error",
94162
"react/no-unknown-property": "warn",
95163

96-
"jsx-a11y/alt-text": "error",
97-
"jsx-a11y/anchor-is-valid": "warn",
98-
"jsx-a11y/click-events-have-key-events": "warn",
99-
"jsx-a11y/no-static-element-interactions": "warn",
100-
"jsx-a11y/no-noninteractive-element-interactions": "warn",
101-
"jsx-a11y/role-has-required-aria-props": "error",
102-
"jsx-a11y/no-autofocus": "warn",
103-
"jsx-a11y/heading-has-content": "warn",
104-
"jsx-a11y/html-has-lang": "warn",
105-
"jsx-a11y/no-redundant-roles": "warn",
106-
"jsx-a11y/scope": "warn",
107-
"jsx-a11y/tabindex-no-positive": "warn",
108-
"jsx-a11y/label-has-associated-control": "warn",
109-
"jsx-a11y/no-distracting-elements": "error",
110-
"jsx-a11y/iframe-has-title": "warn",
164+
...getAccessibilityRules(accessibilityPreset),
111165

112166
...(hasReactCompiler ? REACT_COMPILER_RULES : {}),
113167

packages/react-doctor/src/scan.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ export const scan = async (
463463
const options = mergeScanOptions(inputOptions, userConfig);
464464
const { includePaths } = options;
465465
const isDiffMode = includePaths.length > 0;
466+
const effectiveAccessibility = userConfig?.accessibility ?? "minimal";
466467

467468
if (!projectInfo.reactVersion) {
468469
throw new Error("No React dependency found in package.json");
@@ -490,6 +491,7 @@ export const scan = async (
490491
projectInfo.framework,
491492
projectInfo.hasReactCompiler,
492493
jsxIncludePaths,
494+
effectiveAccessibility,
493495
resolvedNodeBinaryPath,
494496
);
495497
lintSpinner?.succeed("Running lint checks.");

packages/react-doctor/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,14 @@ export interface ReactDoctorIgnoreConfig {
167167
files?: string[];
168168
}
169169

170+
export type AccessibilityPreset = "minimal" | "recommended" | "strict";
171+
170172
export interface ReactDoctorConfig {
171173
ignore?: ReactDoctorIgnoreConfig;
172174
lint?: boolean;
173175
deadCode?: boolean;
174176
verbose?: boolean;
175177
diff?: boolean | string;
176178
failOn?: FailOnLevel;
179+
accessibility?: AccessibilityPreset | false;
177180
}

packages/react-doctor/src/utils/run-oxlint.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
SPAWN_ARGS_MAX_LENGTH_CHARS,
1111
} from "../constants.js";
1212
import { createOxlintConfig } from "../oxlint-config.js";
13-
import type { CleanedDiagnostic, Diagnostic, Framework, OxlintOutput } from "../types.js";
13+
import type {
14+
AccessibilityPreset,
15+
CleanedDiagnostic,
16+
Diagnostic,
17+
Framework,
18+
OxlintOutput,
19+
} from "../types.js";
1420
import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js";
1521

1622
const esmRequire = createRequire(import.meta.url);
@@ -251,7 +257,10 @@ const cleanDiagnosticMessage = (
251257
return { message: REACT_COMPILER_MESSAGE, help: rawMessage || help };
252258
}
253259
const cleaned = message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim();
254-
return { message: cleaned || message, help: help || RULE_HELP_MAP[rule] || "" };
260+
return {
261+
message: cleaned || message,
262+
help: help || RULE_HELP_MAP[rule] || "",
263+
};
255264
};
256265

257266
const parseRuleCode = (code: string): { plugin: string; rule: string } => {
@@ -379,6 +388,7 @@ export const runOxlint = async (
379388
framework: Framework,
380389
hasReactCompiler: boolean,
381390
includePaths?: string[],
391+
accessibilityPreset: AccessibilityPreset | false = "minimal",
382392
nodeBinaryPath: string = process.execPath,
383393
): Promise<Diagnostic[]> => {
384394
if (includePaths !== undefined && includePaths.length === 0) {
@@ -387,7 +397,12 @@ export const runOxlint = async (
387397

388398
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
389399
const pluginPath = resolvePluginPath();
390-
const config = createOxlintConfig({ pluginPath, framework, hasReactCompiler });
400+
const config = createOxlintConfig({
401+
pluginPath,
402+
framework,
403+
hasReactCompiler,
404+
accessibilityPreset,
405+
});
391406
const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
392407

393408
try {

0 commit comments

Comments
 (0)