feat: add jsx-a11y/heading-has-content lint rule
MODIFY
packages/@romejs/diagnostics/categories.ts
packages/@romejs/diagnostics/categories.ts
@@ -102,6 +102,7 @@ type LintDiagnosticCategory =
| "lint/emptyMatches"
| "lint/getterReturn"
| "lint/importDefaultBasename"
+ | "lint/jsxA11yHeadingHasContent"
| "lint/jsxA11yHTMLHasLang"
| "lint/jsxA11yIframeHasTitle"
| "lint/jsxA11yImgRedundantAlt"

MODIFY
packages/@romejs/diagnostics/descriptions.ts
packages/@romejs/diagnostics/descriptions.ts
@@ -290,6 +290,10 @@ export const descriptions = createMessages({
},
// @romejs/js-compiler
LINT: {
+ JSX_A11Y_HEADING_HAS_CONTENT: {
+ category: "lint/jsxA11yHeadingHasContent",
+ message: "Headings must have content and the content must be accessible by a screen reader.",
+ },
JSX_A11Y_NO_DISTRACTING_ELEMENTS: (element: string) => ({
category: "lint/jsxA11yNoDistractingElements",
message: `Do not use ${element} elements as they can create visual accessibility issues and are deprecated.`,

MODIFY
packages/@romejs/js-ast-utils/isJSXElement.ts
packages/@romejs/js-ast-utils/isJSXElement.ts
@@ -3,7 +3,10 @@ import doesNodeMatchPattern from "./doesNodeMatchPattern";
export default function isJSXElement(
node: AnyNode,
- name: string,
+ name?: string,
): node is JSXElement {
- return node.type === "JSXElement" && doesNodeMatchPattern(node.name, name);
+ return (
+ node.type === "JSXElement" &&
+ (name ? doesNodeMatchPattern(node.name, name) : true)
+ );
}

MODIFY
packages/@romejs/js-compiler/lint/rules/index.ts
packages/@romejs/js-compiler/lint/rules/index.ts
@@ -16,6 +16,7 @@ import emptyBlocks from "./regular/emptyBlocks";
import emptyMatches from "./regular/emptyMatches";
import getterReturn from "./regular/getterReturn";
import importDefaultBasename from "./regular/importDefaultBasename";
+import jsxA11YHeadingHasContent from "./react/jsxA11yHeadingHasContent";
import jsxA11YHTMLHasLang from "./react/jsxA11yHTMLHasLang";
import jsxA11YIframeHasTitle from "./react/jsxA11yIframeHasTitle";
import jsxA11YImgRedundantAlt from "./react/jsxA11yImgRedundantAlt";
@@ -82,6 +83,7 @@ export const lintTransforms = [
emptyMatches,
getterReturn,
importDefaultBasename,
+ jsxA11YHeadingHasContent,
jsxA11YHTMLHasLang,
jsxA11YIframeHasTitle,
jsxA11YImgRedundantAlt,

ADD
packages/@romejs/js-compiler/lint/rules/react/jsxA11yHeadingHasContent.test.ts
+import {test} from "rome";
+import {testLintMultiple} from "../testHelpers";
+
+test(
+ "jsx a11y heading has content",
+ async (t) => {
+ await testLintMultiple(
+ t,
+ [
+ // INVALID
+ "<h1 />",
+ "<h1><TextWrapper aria-hidden /></h1>",
+ "<h1><div aria-hidden /></h1>",
+ // VALID
+ "<h1>heading</h1>",
+ "<h1><TextWrapper /></h1>",
+ "<h1 dangerouslySetInnerHTML={{ __html: 'heading' }} />",
+ ],
+ {category: "lint/jsxA11yHeadingHasContent"},
+ );
+ },
+);

ADD
packages/@romejs/js-compiler/lint/rules/react/jsxA11yHeadingHasContent.ts
+import {Path, TransformExitResult} from "@romejs/js-compiler";
+import {descriptions} from "@romejs/diagnostics";
+import {hasJSXAttribute, isJSXElement} from "@romejs/js-ast-utils";
+import {AnyNode} from "@romejs/js-ast";
+
+const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
+
+function isInvalidHeading(node: AnyNode) {
+ if (!isJSXElement(node)) {
+ return false;
+ }
+
+ return (
+ HEADINGS.some((heading) => isJSXElement(node, heading)) &&
+ ((node.children.length === 0 &&
+ !hasJSXAttribute(node, "dangerouslySetInnerHTML")) ||
+ (node.children.length > 0 &&
+ isJSXElement(node.children[0]) &&
+ hasJSXAttribute(node.children[0], "aria-hidden")))
+ );
+}
+
+export default {
+ name: "jsxA11yHeadingHasContent",
+ enter(path: Path): TransformExitResult {
+ const {node} = path;
+
+ if (isInvalidHeading(node)) {
+ path.context.addNodeDiagnostic(
+ node,
+ descriptions.LINT.JSX_A11Y_HEADING_HAS_CONTENT,
+ );
+ }
+
+ return node;
+ },
+};

feat: add jsx-a11y/heading-has-content lint rule