Commits (31)
# html-validate changelog
## [4.7.0](https://gitlab.com/html-validate/html-validate/compare/v4.6.1...v4.7.0) (2021-03-14)
### Features
- new rule `aria-label-misuse` ([b8c6eb7](https://gitlab.com/html-validate/html-validate/commit/b8c6eb7a12849dd9ce08e8d64fbc3aaec5b6d278)), closes [#110](https://gitlab.com/html-validate/html-validate/issues/110)
- support `.htmlvalidate.js` ([b694ddf](https://gitlab.com/html-validate/html-validate/commit/b694ddfa1afa05eb86689aa590a8d232d0d20f66)), closes [#111](https://gitlab.com/html-validate/html-validate/issues/111)
### Bug Fixes
- **dom:** `input[type="hidden"]` no longer labelable ([244d37d](https://gitlab.com/html-validate/html-validate/commit/244d37d3195afb50f75eed0b835f66c325d941e3))
### [4.6.1](https://gitlab.com/html-validate/html-validate/compare/v4.6.0...v4.6.1) (2021-03-02)
### Bug Fixes
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/aria-label-misuse.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/aria-label-misuse.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 22,
"context": undefined,
"line": 1,
"message": "\\"aria-label\\" cannot be used on this element",
"offset": 21,
"ruleId": "aria-label-misuse",
"selector": "input",
"severity": 2,
"size": 10,
},
],
"source": "<input type=\\"hidden\\" aria-label=\\"foobar\\">",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<input type="hidden" aria-label="foobar">`;
markup["correct"] = `<input type="text" aria-label="foobar">`;
describe("docs/rules/aria-label-misuse.md", () => {
it("inline validation: incorrect", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"aria-label-misuse":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"aria-label-misuse":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: aria-label-misuse
category: a17y
summary: Disallow `aria-label` misuse
---
# Disallow `aria-label` misuse (`aria-label-misuse`)
`aria-label` is used to set the label of an element when no native text is present or non-descriptive.
The attribute can only be used on the following elements:
- Interactive elements
- Labelable elements
- Landmark elements
- Elements with roles inheriting from widget
- `<area>`
- `<form>` and `<fieldset>`
- `<iframe>`
- `<img>` and `<figure>`
- `<summary>`
- `<table>`, `<td>` and `<th>`
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="aria-label-misuse">
<input type="hidden" aria-label="foobar">
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="aria-label-misuse">
<input type="text" aria-label="foobar">
</validate>
......@@ -29,6 +29,13 @@ Run with:
## Configuration
Configuration can be added to:
- `.htmlvalidate.js`
- `.htmlvalidate.json`
Configuration files will be searched from the target file and up until either no more parent folders exist or `"root": true` is found.
### `extends`
Configuration can be extended from bundled preset or shareable configurations.
......@@ -148,11 +155,11 @@ path to use a local script (use `<rootDir>` to refer to the path to
By default, configuration is search in the file structure until the root
directory (typically `/`) is found:
- `/home/user/project/src/.htmlvalidate.json`
- `/home/user/project/.htmlvalidate.json`
- `/home/user/.htmlvalidate.json`
- `/home/.htmlvalidate.json`
- `/.htmlvalidate.json`
- `/home/user/project/src/.htmlvalidate.{js,json}`
- `/home/user/project/.htmlvalidate.{js,json}`
- `/home/user/.htmlvalidate.{js,json}`
- `/home/.htmlvalidate.{js,json}`
- `/.htmlvalidate.{js,json}`
By setting the `root` property to `true` the search is stopped. This can be used
to prevent searching from outside the project directory or to use a specific
......@@ -168,8 +175,8 @@ For instance, if `/home/project/.htmlvalidate.json` contains:
only the following files would be searched:
- `/home/user/project/src/.htmlvalidate.json`
- `/home/user/project/.htmlvalidate.json`
- `/home/user/project/src/.htmlvalidate.{js,json}`
- `/home/user/project/.htmlvalidate.{js,json}`
This also affects CLI `--config` and the API, e.g. when using `--config` with a
configuration using `"root": true` will prevent any additional files to be
......
......@@ -533,7 +533,7 @@
"phrasing": true,
"interactive": ["matchAttribute", ["type", "!=", "hidden"]],
"void": true,
"labelable": true,
"labelable": ["matchAttribute", ["type", "!=", "hidden"]],
"deprecatedAttributes": [
"datasrc",
"datafld",
......
import { Source } from "../src/context";
import { HtmlElement } from "../src/dom";
import HtmlValidate from "../src/htmlvalidate";
import "../src/matchers";
......@@ -178,6 +180,37 @@ describe("HTML elements", () => {
},
});
function getElement(markup: string, selector: string): HtmlElement {
const source: Source = {
data: markup,
filename: "inline",
line: 1,
column: 1,
offset: 0,
};
const parser = htmlvalidate.getParserFor(source);
const doc = parser.parseHtml(source.data);
return doc.querySelector(selector);
}
describe("<input>", () => {
it("should be labelable unless hidden", () => {
expect.assertions(1);
const markup = '<input type="text">';
const input = getElement(markup, "input");
const meta = input.meta;
expect(meta?.labelable).toBeTruthy();
});
it("should not be labelable if hidden", () => {
expect.assertions(1);
const markup = '<input type="hidden">';
const input = getElement(markup, "input");
const meta = input.meta;
expect(meta?.labelable).toBeFalsy();
});
});
describe(`global attributes`, () => {
it("valid markup", () => {
expect.assertions(1);
......
This diff is collapsed.
{
"name": "html-validate",
"version": "4.6.1",
"version": "4.7.0",
"description": "html linter",
"keywords": [
"html",
......@@ -96,14 +96,14 @@
"prompts": "^2.0.0"
},
"devDependencies": {
"@babel/core": "7.13.8",
"@babel/preset-env": "7.13.8",
"@babel/core": "7.13.10",
"@babel/preset-env": "7.13.10",
"@commitlint/cli": "12.0.1",
"@html-validate/commitlint-config": "1.3.1",
"@html-validate/eslint-config": "4.1.0",
"@html-validate/eslint-config-jest": "4.0.0",
"@html-validate/eslint-config-typescript": "4.0.0",
"@html-validate/jest-config": "1.2.6",
"@html-validate/jest-config": "1.2.7",
"@html-validate/prettier-config": "1.1.0",
"@html-validate/semantic-release-config": "1.2.6",
"@lodder/grunt-postcss": "3.0.0",
......@@ -114,47 +114,47 @@
"@types/jest": "26.0.20",
"@types/json-merge-patch": "0.0.5",
"@types/minimist": "1.2.1",
"@types/node": "11.15.47",
"@types/node": "11.15.48",
"@types/prompts": "2.0.9",
"autoprefixer": "10.2.4",
"autoprefixer": "10.2.5",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
"canonical-path": "1.0.0",
"cssnano": "4.1.10",
"dgeni": "0.4.13",
"dgeni": "0.4.14",
"dgeni-front-matter": "2.0.3",
"dgeni-packages": "0.28.4",
"eslint": "7.21.0",
"eslint": "7.22.0",
"eslint-plugin-security": "1.4.0",
"eslint-plugin-sonarjs": "0.6.0",
"font-awesome": "4.7.0",
"front-matter": "4.0.2",
"grunt": "1.3.0",
"grunt-browserify": "5.3.0",
"grunt-browserify": "6.0.0",
"grunt-cli": "1.3.2",
"grunt-contrib-connect": "3.0.0",
"grunt-contrib-copy": "1.0.0",
"grunt-sass": "3.1.0",
"highlight.js": "10.6.0",
"husky": "5.1.1",
"husky": "5.1.3",
"jest": "26.6.3",
"jest-diff": "26.6.2",
"jquery": "3.5.1",
"jquery": "3.6.0",
"lint-staged": "10.5.4",
"load-grunt-tasks": "5.1.0",
"marked": "2.0.1",
"minimatch": "3.0.4",
"npm-pkg-lint": "1.3.0",
"postcss": "8.2.6",
"postcss": "8.2.8",
"prettier": "2.2.1",
"pretty-format": "26.6.2",
"sass": "1.32.8",
"semantic-release": "17.4.0",
"semantic-release": "17.4.2",
"serve-static": "1.14.1",
"stringmap": "0.2.2",
"strip-ansi": "6.0.0",
"ts-jest": "26.5.2",
"typescript": "4.2.2"
"ts-jest": "26.5.3",
"typescript": "4.2.3"
},
"engines": {
"node": ">= 10.0"
......
......@@ -11,6 +11,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "off",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -70,6 +71,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "off",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -162,6 +164,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "off",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -222,6 +225,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "off",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -282,6 +286,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -343,6 +348,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -374,6 +380,25 @@ Array [
]
`;
exports[`ConfigLoader smoketest test-files/config/js-config/file.html 1`] = `
Object {
"extends": Array [
"html-validate:recommended",
],
"plugins": Array [],
"root": true,
"rules": Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "off",
},
"transform": Object {},
}
`;
exports[`ConfigLoader smoketest test-files/config/js-config/file.html 2`] = `Array []`;
exports[`ConfigLoader smoketest test-files/config/off/error/file.html 1`] = `
Object {
"extends": Array [
......@@ -385,6 +410,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "error",
},
"transform": Object {},
}
......@@ -427,6 +453,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "off",
},
"transform": Object {},
}
......@@ -445,6 +472,7 @@ Object {
"deprecated": "error",
"element-permitted-content": "error",
"no-self-closing": "error",
"void-content": "warn",
},
"transform": Object {},
}
......
......@@ -97,8 +97,11 @@ describe("ConfigLoader", () => {
expect.objectContaining({
mockFilenames: [
/* ConfigMock adds all visited filenames to this array */
path.resolve("/.htmlvalidate.js"),
path.resolve("/.htmlvalidate.json"),
path.resolve("/path/.htmlvalidate.js"),
path.resolve("/path/.htmlvalidate.json"),
path.resolve("/path/to/.htmlvalidate.js"),
path.resolve("/path/to/.htmlvalidate.json"),
],
})
......@@ -113,7 +116,9 @@ describe("ConfigLoader", () => {
expect.objectContaining({
mockFilenames: [
/* ConfigMock adds all visited filenames to this array */
path.resolve("/project/root/.htmlvalidate.js"),
path.resolve("/project/root/.htmlvalidate.json"),
path.resolve("/project/root/src/.htmlvalidate.js"),
path.resolve("/project/root/src/.htmlvalidate.json"),
],
})
......@@ -137,8 +142,11 @@ describe("ConfigLoader", () => {
expect.objectContaining({
mockFilenames: [
/* ConfigMock adds all visited filenames to this array */
path.resolve("/.htmlvalidate.js"),
path.resolve("/.htmlvalidate.json"),
path.resolve("/path/.htmlvalidate.js"),
path.resolve("/path/.htmlvalidate.json"),
path.resolve("/path/to/.htmlvalidate.js"),
path.resolve("/path/to/.htmlvalidate.json"),
],
})
......@@ -159,8 +167,11 @@ describe("ConfigLoader", () => {
expect.objectContaining({
mockFilenames: [
/* ConfigMock adds all visited filenames to this array */
path.resolve("/.htmlvalidate.js"),
path.resolve("/.htmlvalidate.json"),
path.resolve("/path/.htmlvalidate.js"),
path.resolve("/path/.htmlvalidate.json"),
path.resolve("/path/to/.htmlvalidate.js"),
path.resolve("/path/to/.htmlvalidate.json"),
],
})
......@@ -207,7 +218,12 @@ describe("ConfigLoader", () => {
/* extract only relevant rules from configuration to avoid bloat when new
* rules are added to recommended config */
function filter(src: Config): ConfigData {
const whitelisted = ["no-self-closing", "deprecated", "element-permitted-content"];
const whitelisted = [
"no-self-closing",
"deprecated",
"element-permitted-content",
"void-content",
];
const data = { rules: {}, ...src.get() };
data.rules = Object.keys(data.rules)
.filter((key) => whitelisted.includes(key))
......
......@@ -63,10 +63,16 @@ export class ConfigLoader {
// eslint-disable-next-line no-constant-condition
while (true) {
const search = path.join(current, ".htmlvalidate.json");
const jsonFile = path.join(current, ".htmlvalidate.json");
if (fs.existsSync(jsonFile)) {
const local = this.configClass.fromFile(jsonFile);
found = true;
config = local.merge(config);
}
if (fs.existsSync(search)) {
const local = this.configClass.fromFile(search);
const jsFile = path.join(current, ".htmlvalidate.js");
if (fs.existsSync(jsFile)) {
const local = this.configClass.fromFile(jsFile);
found = true;
config = local.merge(config);
}
......
......@@ -2,6 +2,7 @@ import { ConfigData } from "../config-data";
const config: ConfigData = {
rules: {
"aria-label-misuse": "error",
"deprecated-rule": "warn",
"empty-heading": "error",
"empty-title": "error",
......
......@@ -2,9 +2,10 @@ import { ConfigData } from "../config-data";
const config: ConfigData = {
rules: {
"aria-label-misuse": "error",
"attr-case": "error",
"attr-spacing": "error",
"attr-quotes": "error",
"attr-spacing": "error",
"attribute-allowed-values": "error",
"attribute-boolean-style": "error",
"attribute-empty-style": "error",
......
......@@ -11,7 +11,7 @@ exports[`codeframe formatter should generate plaintext 1`] = `
> 2 | class=\\"bar\\"
| ^^^^^^^^^^^^^^^
3 | name=\\"baz\\">
4 |
4 |
<yellow>warning</>: <bold>A warning</> <dim>(bar)</> at <green>regular.html:2:4</>:
......@@ -19,7 +19,7 @@ exports[`codeframe formatter should generate plaintext 1`] = `
> 2 | class=\\"bar\\"
| ^
3 | name=\\"baz\\">
4 |
4 |
<red>error</>: <bold>Another error</> <dim>(baz)</> at <green>edge-cases.html:3:3</>:
......@@ -27,7 +27,7 @@ exports[`codeframe formatter should generate plaintext 1`] = `
2 | class=\\"bar\\"
> 3 | name=\\"baz\\">
| ^
4 |
4 |
<red><bold>2 errors and 1 warning found.</></>
......
......@@ -55,7 +55,7 @@ export interface MetaData {
implicitClosed?: string[];
scriptSupporting?: boolean;
form?: boolean;
labelable?: boolean;
labelable?: boolean | PropertyExpression;
/* attribute */
deprecatedAttributes?: string[];
......
......@@ -23,6 +23,7 @@ const dynamicKeys = [
"phrasing",
"embedded",
"interactive",
"labelable",
];
type PropertyEvaluator = (node: HtmlElement, options: any) => boolean;
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule aria-label-misuse should contain documentation 1`] = `
Object {
"description": "\`aria-label\` can only be used on:
- Interactive elements
- Labelable elements
- Landmark elements
- Elements with roles inheriting from widget
- \`<area>\`
- \`<form>\` and \`<fieldset>\`
- \`<iframe>\`
- \`<img>\` and \`<figure>\`
- \`<summary>\`
- \`<table>\`, \`<td>\` and \`<th>\`
",
"url": "https://html-validate.org/rules/aria-label-misuse.html",
}
`;
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
describe("rule aria-label-misuse", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "aria-label-misuse": ["error"] },
});
});
it("should not report for element without aria-label", () => {
expect.assertions(1);
const markup = "<p></p>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with empty aria-label", () => {
expect.assertions(1);
const markup = '<p aria-label=""></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with boolean aria-label", () => {
expect.assertions(1);
const markup = "<p aria-label></p>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element without meta", () => {
expect.assertions(1);
const markup = '<custom-element aria-label="foobar"></custom-element>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report for element with role", () => {
expect.assertions(1);
const markup = '<p aria-label="foobar" role="widget"></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
describe("should not report error for", () => {
it.each`
markup | description
${'<button aria-label="foobar"></button>'} | ${"Interactive elements"}
${'<input type="text" aria-label="foobar">'} | ${"Labelable elements"}
${'<main aria-label="foobar"></main>'} | ${"Landmark elements"}
${'<p role="widget" aria-label="foobar">'} | ${'[role=".."]'}
${'<p tabindex="0" aria-label="foobar"></p>'} | ${"[tabindex]"}
${'<area aria-label="foobar"></area>'} | ${"<area>"}
${'<form aria-label="foobar"></form>'} | ${"<form>"}
${'<fieldset aria-label="foobar"></fieldset>'} | ${"<fieldset>"}
${'<iframe aria-label="foobar"></iframe>'} | ${"<iframe>"}
${'<img aria-label="foobar">'} | ${"<img>"}
${'<figure aria-label="foobar"></figure>'} | ${"<figure>"}
${'<summary aria-label="foobar"></summary>'} | ${"<summary>"}
${'<table aria-label="foobar"></table>'} | ${"<table>"}
${'<td aria-label="foobar"></td>'} | ${"<td>"}
${'<th aria-label="foobar"></th>'} | ${"<th>"}
`("$description", ({ markup }) => {
expect.assertions(1);
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
it("should report error when aria-label is used on invalid element", () => {
expect.assertions(2);
const markup = '<p aria-label="foobar"></p>';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should report error when aria-label is used on input hidden", () => {
expect.assertions(2);
const markup = '<input type="hidden" aria-label="foobar">';
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should handle dynamic attribute", () => {
expect.assertions(2);
const markup = '<p dynamic-aria-label="foobar"></p>';
const report = htmlvalidate.validateString(markup, { processAttribute });
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should handle interpolated attribute", () => {
expect.assertions(2);
const markup = '<p aria-label="{{ interpolated }}"></p>';
const report = htmlvalidate.validateString(markup, { processAttribute });
expect(report).toBeInvalid();
expect(report).toHaveError("aria-label-misuse", '"aria-label" cannot be used on this element');
});
it("should contain documentation", () => {
expect.assertions(1);
expect(htmlvalidate.getRuleDocumentation("aria-label-misuse")).toMatchSnapshot();
});
});
import { HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const whitelisted = [
"main",
"nav",
"table",
"td",
"th",
"aside",
"header",
"footer",
"section",
"article",
"form",
"img",
"area",
"fieldset",
"summary",
"figure",
];
export default class AriaLabelMisuse extends Rule {
public documentation(): RuleDocumentation {
const valid = [
"Interactive elements",
"Labelable elements",
"Landmark elements",
"Elements with roles inheriting from widget",
"`<area>`",
"`<form>` and `<fieldset>`",
"`<iframe>`",
"`<img>` and `<figure>`",
"`<summary>`",
"`<table>`, `<td>` and `<th>`",
];
const lines = valid.map((it) => `- ${it}\n`).join("");
return {
description: `\`aria-label\` can only be used on:\n\n${lines}`,
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("dom:ready", (event: DOMReadyEvent) => {
const { document } = event;
for (const target of document.querySelectorAll("[aria-label]")) {
this.validateElement(target);
}
});
}
private validateElement(target: HtmlElement): void {
const attr = target.getAttribute("aria-label");
if (!attr || !attr.value || attr.valueMatches("", false)) {
return;
}
/* ignore elements without meta */
const meta = target.meta;
if (!meta) {
return;
}
/* ignore landmark and other whitelisted elements */
if (whitelisted.includes(target.tagName)) {
return;
}
/* ignore elements with role, @todo check if the role is widget or landmark */
if (target.hasAttribute("role")) {
return;
}
/* ignore elements with tabindex (implicit interactive) */
if (target.hasAttribute("tabindex")) {
return;
}
/* ignore interactive and labelable elements */
if (meta.interactive || meta.labelable) {
return;
}
this.report(target, `"aria-label" cannot be used on this element`, attr.keyLocation);
}
}