Skip to content

Commit ce65144

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 618350f commit ce65144

File tree

6 files changed

+292
-22
lines changed

6 files changed

+292
-22
lines changed

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",
@@ -45,12 +107,14 @@ interface OxlintConfigOptions {
45107
pluginPath: string;
46108
framework: Framework;
47109
hasReactCompiler: boolean;
110+
accessibilityPreset: AccessibilityPreset | false;
48111
}
49112

50113
export const createOxlintConfig = ({
51114
pluginPath,
52115
framework,
53116
hasReactCompiler,
117+
accessibilityPreset,
54118
}: OxlintConfigOptions) => ({
55119
categories: {
56120
correctness: "off",
@@ -61,7 +125,11 @@ export const createOxlintConfig = ({
61125
style: "off",
62126
nursery: "off",
63127
},
64-
plugins: ["react", "jsx-a11y", ...(hasReactCompiler ? [] : ["react-perf"])],
128+
plugins: [
129+
"react",
130+
...(accessibilityPreset !== false ? ["jsx-a11y"] : []),
131+
...(hasReactCompiler ? [] : ["react-perf"]),
132+
],
65133
jsPlugins: [
66134
...(hasReactCompiler
67135
? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }]
@@ -82,21 +150,7 @@ export const createOxlintConfig = ({
82150
"react/require-render-return": "error",
83151
"react/no-unknown-property": "warn",
84152

85-
"jsx-a11y/alt-text": "error",
86-
"jsx-a11y/anchor-is-valid": "warn",
87-
"jsx-a11y/click-events-have-key-events": "warn",
88-
"jsx-a11y/no-static-element-interactions": "warn",
89-
"jsx-a11y/no-noninteractive-element-interactions": "warn",
90-
"jsx-a11y/role-has-required-aria-props": "error",
91-
"jsx-a11y/no-autofocus": "warn",
92-
"jsx-a11y/heading-has-content": "warn",
93-
"jsx-a11y/html-has-lang": "warn",
94-
"jsx-a11y/no-redundant-roles": "warn",
95-
"jsx-a11y/scope": "warn",
96-
"jsx-a11y/tabindex-no-positive": "warn",
97-
"jsx-a11y/label-has-associated-control": "warn",
98-
"jsx-a11y/no-distracting-elements": "error",
99-
"jsx-a11y/iframe-has-title": "warn",
153+
...getAccessibilityRules(accessibilityPreset),
100154

101155
...(hasReactCompiler ? REACT_COMPILER_RULES : {}),
102156

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
@@ -157,10 +157,13 @@ export interface ReactDoctorIgnoreConfig {
157157
files?: string[];
158158
}
159159

160+
export type AccessibilityPreset = "minimal" | "recommended" | "strict";
161+
160162
export interface ReactDoctorConfig {
161163
ignore?: ReactDoctorIgnoreConfig;
162164
lint?: boolean;
163165
deadCode?: boolean;
164166
verbose?: boolean;
165167
diff?: boolean | string;
168+
accessibility?: AccessibilityPreset | false;
166169
}

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);
@@ -225,7 +231,10 @@ const cleanDiagnosticMessage = (
225231
return { message: REACT_COMPILER_MESSAGE, help: rawMessage || help };
226232
}
227233
const cleaned = message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim();
228-
return { message: cleaned || message, help: help || RULE_HELP_MAP[rule] || "" };
234+
return {
235+
message: cleaned || message,
236+
help: help || RULE_HELP_MAP[rule] || "",
237+
};
229238
};
230239

231240
const parseRuleCode = (code: string): { plugin: string; rule: string } => {
@@ -353,6 +362,7 @@ export const runOxlint = async (
353362
framework: Framework,
354363
hasReactCompiler: boolean,
355364
includePaths?: string[],
365+
accessibilityPreset: AccessibilityPreset | false = "minimal",
356366
nodeBinaryPath: string = process.execPath,
357367
): Promise<Diagnostic[]> => {
358368
if (includePaths !== undefined && includePaths.length === 0) {
@@ -361,7 +371,12 @@ export const runOxlint = async (
361371

362372
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
363373
const pluginPath = resolvePluginPath();
364-
const config = createOxlintConfig({ pluginPath, framework, hasReactCompiler });
374+
const config = createOxlintConfig({
375+
pluginPath,
376+
framework,
377+
hasReactCompiler,
378+
accessibilityPreset,
379+
});
365380
const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory);
366381

367382
try {

0 commit comments

Comments
 (0)