Commit 2afcd86c authored by David Sveningsson's avatar David Sveningsson
Browse files

fix(rules): `no-missing-references` handles attributes with reference lists

fixes #133
parent 7bef736b
......@@ -5,7 +5,7 @@ exports[`docs/rules/no-missing-references.md inline validation: correct 1`] = `A
exports[`docs/rules/no-missing-references.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 3,
"errorCount": 4,
"filePath": "inline",
"messages": Array [
Object {
......@@ -53,10 +53,25 @@ Array [
"severity": 2,
"size": 12,
},
Object {
"column": 37,
"context": Object {
"key": "aria-describedby",
"value": "another-missing",
},
"line": 3,
"message": "Element references missing id \\"another-missing\\"",
"offset": 115,
"ruleId": "no-missing-references",
"ruleUrl": "https://html-validate.org/rules/no-missing-references.html",
"selector": "div:nth-child(3)",
"severity": 2,
"size": 15,
},
],
"source": "<label for=\\"missing-input\\"></label>
<div aria-labelledby=\\"missing-text\\"></div>
<div aria-describedby=\\"missing-text\\"></div>",
<div aria-describedby=\\"missing-text another-missing\\"></div>",
"warningCount": 0,
},
]
......
......@@ -3,11 +3,12 @@ import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<label for="missing-input"></label>
<div aria-labelledby="missing-text"></div>
<div aria-describedby="missing-text"></div>`;
<div aria-describedby="missing-text another-missing"></div>`;
markup["correct"] = `<label for="my-input"></label>
<div id="verbose-text"></div>
<div id="another-text"></div>
<div aria-labelledby="verbose-text"></div>
<div aria-describedby="verbose-text"></div>
<div aria-describedby="verbose-text another-text"></div>
<input id="my-input">`;
describe("docs/rules/no-missing-references.md", () => {
......
......@@ -25,7 +25,7 @@ Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="no-missing-references">
<label for="missing-input"></label>
<div aria-labelledby="missing-text"></div>
<div aria-describedby="missing-text"></div>
<div aria-describedby="missing-text another-missing"></div>
</validate>
Examples of **correct** code for this rule:
......@@ -33,7 +33,8 @@ Examples of **correct** code for this rule:
<validate name="correct" rules="no-missing-references">
<label for="my-input"></label>
<div id="verbose-text"></div>
<div id="another-text"></div>
<div aria-labelledby="verbose-text"></div>
<div aria-describedby="verbose-text"></div>
<div aria-describedby="verbose-text another-text"></div>
<input id="my-input">
</validate>
......@@ -78,23 +78,32 @@ describe("rule no-missing-references", () => {
it('should report error when <ANY aria-labelledby=".."> is referencing missing element', () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<div aria-labelledby="missing"></div>');
const report = htmlvalidate.validateString('<div aria-labelledby="missing id"></div>');
expect(report).toBeInvalid();
expect(report).toHaveError("no-missing-references", 'Element references missing id "missing"');
expect(report).toHaveErrors([
{ ruleId: "no-missing-references", message: 'Element references missing id "missing"' },
{ ruleId: "no-missing-references", message: 'Element references missing id "id"' },
]);
});
it('should report error when <ANY aria-describedby=".."> is referencing missing element', () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<div aria-describedby="missing"></div>');
const report = htmlvalidate.validateString('<div aria-describedby="missing id"></div>');
expect(report).toBeInvalid();
expect(report).toHaveError("no-missing-references", 'Element references missing id "missing"');
expect(report).toHaveErrors([
{ ruleId: "no-missing-references", message: 'Element references missing id "missing"' },
{ ruleId: "no-missing-references", message: 'Element references missing id "id"' },
]);
});
it('should report error when <ANY aria-controls=".."> is referencing missing element', () => {
expect.assertions(2);
const report = htmlvalidate.validateString('<div aria-controls="missing"></div>');
const report = htmlvalidate.validateString('<div aria-controls="missing id"></div>');
expect(report).toBeInvalid();
expect(report).toHaveError("no-missing-references", 'Element references missing id "missing"');
expect(report).toHaveErrors([
{ ruleId: "no-missing-references", message: 'Element references missing id "missing"' },
{ ruleId: "no-missing-references", message: 'Element references missing id "id"' },
]);
});
it("should contain documentation", () => {
......
import { Attribute, DOMTree, DynamicValue, HtmlElement } from "../dom";
import { Attribute, DOMTokenList, DOMTree, DynamicValue, HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
......@@ -7,7 +7,21 @@ interface Context {
value: string;
}
const ARIA = ["aria-controls", "aria-describedby", "aria-labelledby"];
interface AriaAttribute {
property: string;
isList: boolean;
}
const ARIA: AriaAttribute[] = [
{ property: "aria-controls", isList: true },
{ property: "aria-describedby", isList: true },
{ property: "aria-labelledby", isList: true },
];
function idMissing(document: DOMTree, id: string): boolean {
const nodes = document.querySelectorAll(`[id="${id}"]`);
return nodes.length === 0;
}
export default class NoMissingReferences extends Rule<Context> {
public documentation(context: Context): RuleDocumentation {
......@@ -31,42 +45,75 @@ export default class NoMissingReferences extends Rule<Context> {
/* verify <label for=".."> */
for (const node of document.querySelectorAll("label[for]")) {
const attr = node.getAttribute("for");
this.validateReference(document, node, attr);
this.validateReference(document, node, attr, false);
}
/* verify <input list=".."> */
for (const node of document.querySelectorAll("input[list]")) {
const attr = node.getAttribute("list");
this.validateReference(document, node, attr);
this.validateReference(document, node, attr, false);
}
/* verify WAI-ARIA properties */
for (const property of ARIA) {
for (const { property, isList } of ARIA) {
for (const node of document.querySelectorAll(`[${property}]`)) {
const attr = node.getAttribute(property);
this.validateReference(document, node, attr);
this.validateReference(document, node, attr, isList);
}
}
});
}
protected validateReference(document: DOMTree, node: HtmlElement, attr: Attribute | null): void {
protected validateReference(
document: DOMTree,
node: HtmlElement,
attr: Attribute | null,
isList: boolean
): void {
/* sanity check: querySelector should never return elements without the attribute */
/* istanbul ignore next */
if (!attr) {
return;
}
const id = attr.value;
if (id instanceof DynamicValue || id === null || id === "") {
/* skip dynamic and empty values */
const value = attr.value;
if (value instanceof DynamicValue || value === null || value === "") {
return;
}
const nodes = document.querySelectorAll(`[id="${id}"]`);
if (nodes.length === 0) {
if (isList) {
this.validateList(document, node, attr, value);
} else {
this.validateSingle(document, node, attr, value);
}
}
protected validateSingle(
document: DOMTree,
node: HtmlElement,
attr: Attribute,
id: string
): void {
if (idMissing(document, id)) {
const context: Context = { key: attr.key, value: id };
this.report(node, `Element references missing id "${id}"`, attr.valueLocation, context);
}
}
protected validateList(
document: DOMTree,
node: HtmlElement,
attr: Attribute,
values: string
): void {
const parsed = new DOMTokenList(values, attr.valueLocation);
for (const entry of parsed.iterator()) {
const id = entry.item;
if (idMissing(document, id)) {
const context: Context = { key: attr.key, value: id };
this.report(node, `Element references missing id "${id}"`, entry.location, context);
}
}
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment