80 lines
3.2 KiB
JavaScript
80 lines
3.2 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
|
|
const root = path.resolve("src");
|
|
const files = [];
|
|
function collect(directory) {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) collect(target);
|
|
else if (/\.tsx?$/.test(entry.name)) files.push(target);
|
|
}
|
|
}
|
|
collect(root);
|
|
|
|
const issues = [];
|
|
for (const file of files) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
const labelIds = new Set();
|
|
function findLabels(node) {
|
|
if (ts.isJsxOpeningElement(node) && node.tagName.getText(tree) === "label") {
|
|
const htmlFor = node.attributes.properties.find((item) =>
|
|
ts.isJsxAttribute(item) && item.name.getText(tree) === "htmlFor");
|
|
if (htmlFor?.initializer && ts.isStringLiteral(htmlFor.initializer)) {
|
|
labelIds.add(htmlFor.initializer.text);
|
|
}
|
|
}
|
|
ts.forEachChild(node, findLabels);
|
|
}
|
|
findLabels(tree);
|
|
|
|
function inspect(node) {
|
|
let opening = null;
|
|
if (ts.isJsxSelfClosingElement(node)) opening = node;
|
|
else if (ts.isJsxElement(node)) opening = node.openingElement;
|
|
if (opening) {
|
|
const tag = opening.tagName.getText(tree);
|
|
if (["input", "select", "textarea"].includes(tag)) {
|
|
const attrs = opening.attributes.properties.filter(ts.isJsxAttribute);
|
|
const hasAccessibleName = attrs.some((attr) => {
|
|
const name = attr.name.getText(tree);
|
|
if (!["aria-label", "aria-labelledby", "title"].includes(name)) return false;
|
|
return Boolean(attr.initializer && (
|
|
(ts.isStringLiteral(attr.initializer) && attr.initializer.text.trim())
|
|
|| ts.isJsxExpression(attr.initializer)
|
|
));
|
|
});
|
|
const idAttr = attrs.find((attr) => attr.name.getText(tree) === "id");
|
|
const id = idAttr?.initializer && ts.isStringLiteral(idAttr.initializer)
|
|
? idAttr.initializer.text : null;
|
|
let parent = node.parent;
|
|
let wrappedByLabel = false;
|
|
while (parent) {
|
|
if (ts.isJsxElement(parent) && parent.openingElement.tagName.getText(tree) === "label") {
|
|
wrappedByLabel = true;
|
|
break;
|
|
}
|
|
parent = parent.parent;
|
|
}
|
|
const typeAttr = attrs.find((attr) => attr.name.getText(tree) === "type");
|
|
const hidden = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer)
|
|
&& typeAttr.initializer.text === "hidden";
|
|
if (!hidden && !hasAccessibleName && !wrappedByLabel && !(id && labelIds.has(id))) {
|
|
const { line } = tree.getLineAndCharacterOfPosition(opening.getStart(tree));
|
|
issues.push(`${path.relative(root, file)}:${line + 1} <${tag}>`);
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, inspect);
|
|
}
|
|
inspect(tree);
|
|
}
|
|
|
|
if (issues.length) {
|
|
console.error(`Элементы формы без доступного имени (${issues.length}):\n${issues.join("\n")}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("Доступные имена input/select/textarea: проверены.");
|
|
}
|