Commits (9)
# html-validate changelog
# [3.5.0](https://gitlab.com/html-validate/html-validate/compare/v3.4.1...v3.5.0) (2020-10-18)
### Features
- **rules:** new rule `no-multiple-main` ([fa3c065](https://gitlab.com/html-validate/html-validate/commit/fa3c065f2968829bafd0c20ae52158d725be27ca))
## [3.4.1](https://gitlab.com/html-validate/html-validate/compare/v3.4.0...v3.4.1) (2020-10-13)
### Bug Fixes
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/no-multiple-main.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/no-multiple-main.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 2,
"context": undefined,
"line": 2,
"message": "Multiple <main> elements present in document",
"offset": 18,
"ruleId": "no-multiple-main",
"selector": "main:nth-child(2)",
"severity": 2,
"size": 4,
},
],
"source": "<main>foo</main>
<main>bar</main>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<main>foo</main>
<main>bar</main>`;
markup["correct"] = `<main>foo</main>
<main hidden>bar</main>`;
describe("docs/rules/no-multiple-main.md", () => {
it("inline validation: incorrect", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"no-multiple-main":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"no-multiple-main":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: no-multiple-main
category: content-model
summary: Disallow multiple `<main>`
---
# Disallows multiple `<main>` elements in the same document (`no-multiple-main`)
HTML5 [disallows][whatwg] multiple visible `<main>` element in the same document.
Multiple `<main>` can be present but at most one can be visible and the others must be hidden using the `hidden` attribute.
[whatwg]: https://html.spec.whatwg.org/multipage/grouping-content.html#the-main-element
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="no-multiple-main">
<main>foo</main>
<main>bar</main>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="no-multiple-main">
<main>foo</main>
<main hidden>bar</main>
</validate>
This diff is collapsed.
{
"name": "html-validate",
"version": "3.4.1",
"version": "3.5.0",
"description": "html linter",
"keywords": [
"html",
......@@ -92,8 +92,8 @@
"minimist": "^1.2.0"
},
"devDependencies": {
"@babel/core": "7.11.6",
"@babel/preset-env": "7.11.5",
"@babel/core": "7.12.3",
"@babel/preset-env": "7.12.1",
"@commitlint/cli": "11.0.0",
"@html-validate/commitlint-config": "1.0.3",
"@html-validate/eslint-config": "1.5.22",
......@@ -107,7 +107,7 @@
"@types/jest": "26.0.14",
"@types/json-merge-patch": "0.0.5",
"@types/minimist": "1.2.0",
"@types/node": "11.15.31",
"@types/node": "11.15.32",
"autoprefixer": "9.8.6",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
......@@ -134,14 +134,14 @@
"jest": "26.5.3",
"jest-diff": "26.5.2",
"jquery": "3.5.1",
"lint-staged": "10.4.0",
"lint-staged": "10.4.2",
"load-grunt-tasks": "5.1.0",
"marked": "1.2.0",
"minimatch": "3.0.4",
"prettier": "2.1.2",
"pretty-format": "26.5.2",
"sass": "1.27.0",
"semantic-release": "17.1.2",
"semantic-release": "17.2.1",
"serve-static": "1.14.1",
"stringmap": "0.2.2",
"strip-ansi": "6.0.0",
......
......@@ -32,6 +32,7 @@ const config: ConfigData = {
"no-dup-id": "error",
"no-implicit-close": "error",
"no-inline-style": "error",
"no-multiple-main": "error",
"no-raw-characters": "error",
"no-redundant-for": "error",
"no-redundant-role": "error",
......
......@@ -18,6 +18,7 @@ const config: ConfigData = {
"no-deprecated-attr": "error",
"no-dup-attr": "error",
"no-dup-id": "error",
"no-multiple-main": "error",
"no-raw-characters": ["error", { relaxed: true }],
"script-element": "error",
"unrecognized-char-ref": "error",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule no-multiple-main should contain documentation 1`] = `
Object {
"description": "Only a single visible \`<main>\` element can be present at in a document at a time.
Multiple \`<main>\` can be present in the DOM as long the others are hidden using the HTML5 \`hidden\` attribute.",
"url": "https://html-validate.org/rules/no-multiple-main.html",
}
`;
......@@ -2,7 +2,7 @@ import { Config } from "../../config";
import { DOMTree } from "../../dom";
import { Parser } from "../../parser";
import { processAttribute } from "../../transform/mocks/attribute";
import { inAccessibilityTree } from "./a17y";
import { inAccessibilityTree, isAriaHidden, isPresentation } from "./a17y";
describe("a17y helpers", () => {
let parser: Parser;
......@@ -25,64 +25,157 @@ describe("a17y helpers", () => {
}
describe("inAccessibilityTree()", () => {
it("should return true if node is visibile in accessibility tree", () => {
it('should return false if element has aria-hidden="true"', () => {
expect.assertions(1);
const root = parse("<p>Lorem ipsum</p>");
const root = parse('<p aria-hidden="true">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeTruthy();
expect(inAccessibilityTree(p)).toBeFalsy();
});
it('should return true if node has role="{{ interpolated }}"', () => {
it('should return false if element has role="presentation"', () => {
expect.assertions(1);
const root = parse('<p role="{{ interpolated }}">Lorem ipsum</p>');
const root = parse('<p role="presentation">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
});
it('should return false if ancestor has aria-hidden="true"', () => {
expect.assertions(1);
const root = parse('<div aria-hidden="true"><p>Lorem ipsum</p></div>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
});
it('should return false if ancestor has role="presentation"', () => {
expect.assertions(1);
const root = parse('<div role="presentation"><p>Lorem ipsum</p></div>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
});
it("should return true if element and ancestors are present in accessibility tree", () => {
expect.assertions(1);
const root = parse("<div><p>Lorem ipsum</p></div>");
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeTruthy();
});
});
describe("isAriaHidden()", () => {
it("should return false if node is missing aria-hidden", () => {
expect.assertions(1);
const root = parse("<p>Lorem ipsum</p>");
const p = root.querySelector("p");
expect(isAriaHidden(p)).toBeFalsy();
});
it("should return false if ancestors are missing aria-hidden", () => {
expect.assertions(1);
const root = parse("<div><p>Lorem ipsum</p></div>");
const p = root.querySelector("p");
expect(isAriaHidden(p)).toBeFalsy();
});
it('should return true if node has aria-hidden="{{ interpolated }}"', () => {
it("should return false if node has interpolated aria-hidden", () => {
expect.assertions(1);
const root = parse('<p aria-hidden="{{ interpolated }}">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeTruthy();
expect(isAriaHidden(p)).toBeFalsy();
});
it('should return false if node has role="presentation"', () => {
it("should return false if node has dynamic aria-hidden", () => {
expect.assertions(1);
const root = parse('<p role="presentation">Lorem ipsum</p>');
const root = parse('<p dynamic-aria-hidden="variable">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
expect(isAriaHidden(p)).toBeFalsy();
});
it('should return false if node has aria-hidden="true"', () => {
it('should return true if node has aria-hidden="true"', () => {
expect.assertions(1);
const root = parse('<p aria-hidden="true">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
expect(isAriaHidden(p)).toBeTruthy();
});
it('should return false if parent has role="presentation"', () => {
it('should return true if ancestor has aria-hidden="true"', () => {
expect.assertions(1);
const root = parse('<div role="presentation"><p>Lorem ipsum</p></div>');
const root = parse('<div aria-hidden="true"><p>Lorem ipsum</p></div>');
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
expect(isAriaHidden(p)).toBeTruthy();
});
it('should return false if parent has aria-hidden="true"', () => {
it("should cache result", () => {
expect.assertions(4);
const root = parse('<p aria-hidden="true"></p>');
const p = root.querySelector("p");
const spy = jest.spyOn(p, "getAttribute");
expect(isAriaHidden(p)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockClear();
expect(isAriaHidden(p)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(0);
});
});
describe("isPresentation()", () => {
it("should return false if node is missing role", () => {
expect.assertions(1);
const root = parse('<div aria-hidden="true"><p>Lorem ipsum</p></div>');
const root = parse("<p>Lorem ipsum</p>");
const p = root.querySelector("p");
expect(inAccessibilityTree(p)).toBeFalsy();
expect(isPresentation(p)).toBeFalsy();
});
it("should return false if node has other role", () => {
expect.assertions(1);
const root = parse('<p role="checkbox">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(isPresentation(p)).toBeFalsy();
});
it("should return false if ancestors are missing role", () => {
expect.assertions(1);
const root = parse("<div><p>Lorem ipsum</p></div>");
const p = root.querySelector("p");
expect(isPresentation(p)).toBeFalsy();
});
it("should return false if node has interpolated role", () => {
expect.assertions(1);
const root = parse('<p role="{{ interpolated }}">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(isPresentation(p)).toBeFalsy();
});
it("should return false if node has dynamic role", () => {
expect.assertions(1);
const root = parse('<p dynamic-role="variable">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(isPresentation(p)).toBeFalsy();
});
it('should return true if node has role="presentation"', () => {
expect.assertions(1);
const root = parse('<p role="presentation">Lorem ipsum</p>');
const p = root.querySelector("p");
expect(isPresentation(p)).toBeTruthy();
});
it('should return true if ancestor has role="presentation"', () => {
expect.assertions(1);
const root = parse('<div role="presentation"><p>Lorem ipsum</p></div>');
const p = root.querySelector("p");
expect(isPresentation(p)).toBeTruthy();
});
it("should cache result", () => {
expect.assertions(4);
const root = parse("<p></p>");
const root = parse('<p role="presentation"></p>');
const p = root.querySelector("p");
const spy = jest.spyOn(p, "getAttribute");
expect(inAccessibilityTree(p)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(2);
expect(isPresentation(p)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockClear();
expect(inAccessibilityTree(p)).toBeTruthy();
expect(isPresentation(p)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(0);
});
});
......
......@@ -2,11 +2,13 @@ import { HtmlElement } from "../../dom";
declare module "../../dom/cache" {
export interface DOMNodeCache {
[CACHE_KEY]: boolean;
[ARIA_HIDDEN_CACHE]: boolean;
[ROLE_PRESENTATION_CACHE]: boolean;
}
}
const CACHE_KEY = Symbol(inAccessibilityTree.name);
const ARIA_HIDDEN_CACHE = Symbol(isAriaHidden.name);
const ROLE_PRESENTATION_CACHE = Symbol(isPresentation.name);
/**
* Tests if this element is present in the accessibility tree.
......@@ -16,28 +18,58 @@ const CACHE_KEY = Symbol(inAccessibilityTree.name);
* visible since the element might be in the visibility tree sometimes.
*/
export function inAccessibilityTree(node: HtmlElement): boolean {
if (node.cacheExists(CACHE_KEY)) {
return node.cacheGet(CACHE_KEY);
return !isAriaHidden(node) && !isPresentation(node);
}
/**
* Tests if this element or an ancestor have `aria-hidden="true"`.
*
* Dynamic values yields `false` since the element will conditionally be in the
* accessibility tree and must fulfill it's conditions.
*/
export function isAriaHidden(node: HtmlElement): boolean {
if (node.cacheExists(ARIA_HIDDEN_CACHE)) {
return node.cacheGet(ARIA_HIDDEN_CACHE);
}
let cur: HtmlElement = node;
do {
const role = cur.getAttribute("role");
const ariaHidden = cur.getAttribute("aria-hidden");
/* role="presentation" */
if (role && role.value === "presentation") {
return cur.cacheSet(CACHE_KEY, false);
}
/* aria-hidden="true" */
if (ariaHidden && ariaHidden.value === "true") {
return cur.cacheSet(CACHE_KEY, false);
return cur.cacheSet(ARIA_HIDDEN_CACHE, true);
}
/* check parents */
cur = cur.parent;
} while (!cur.isRootElement());
return node.cacheSet(ARIA_HIDDEN_CACHE, false);
}
/**
* Tests if this element or a parent element has role="presentation".
*
* Dynamic values yields `false` just as if the attribute wasn't present.
*/
export function isPresentation(node: HtmlElement): boolean {
if (node.cacheExists(ROLE_PRESENTATION_CACHE)) {
return node.cacheGet(ROLE_PRESENTATION_CACHE);
}
let cur: HtmlElement = node;
do {
const role = cur.getAttribute("role");
/* role="presentation" */
if (role && role.value === "presentation") {
return cur.cacheSet(ROLE_PRESENTATION_CACHE, true);
}
/* check parents */
cur = cur.parent;
} while (!cur.isRootElement());
return node.cacheSet(CACHE_KEY, true);
return node.cacheSet(ROLE_PRESENTATION_CACHE, false);
}
......@@ -36,6 +36,7 @@ import NoDupId from "./no-dup-id";
import NoImplicitClose from "./no-implicit-close";
import NoInlineStyle from "./no-inline-style";
import NoMissingReferences from "./no-missing-references";
import NoMultipleMain from "./no-multiple-main";
import NoRawCharacters from "./no-raw-characters";
import NoRedundantFor from "./no-redundant-for";
import NoRedundantRole from "./no-redundant-role";
......@@ -94,6 +95,7 @@ const bundledRules: Record<string, RuleConstructor<any, any>> = {
"no-implicit-close": NoImplicitClose,
"no-inline-style": NoInlineStyle,
"no-missing-references": NoMissingReferences,
"no-multiple-main": NoMultipleMain,
"no-raw-characters": NoRawCharacters,
"no-redundant-for": NoRedundantFor,
"no-redundant-role": NoRedundantRole,
......
import HtmlValidate from "../htmlvalidate";
import { processAttribute } from "../transform/mocks/attribute";
import "../matchers";
describe("rule no-multiple-main", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "no-multiple-main": "error" },
});
});
it("should not report when <main> is missing", () => {
expect.assertions(1);
const markup = "<p></p>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report when single <main> is present", () => {
expect.assertions(1);
const markup = "<main></main>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report when other <main> are hidden", () => {
expect.assertions(1);
const markup = "<main>a</main><main hidden>b</main>";
const report = htmlvalidate.validateString(markup, {
processAttribute,
});
expect(report).toBeValid();
});
it("should not report when all <main> are hidden", () => {
expect.assertions(1);
const markup = "<main hidden>a</main><main hidden>b</main>";
const report = htmlvalidate.validateString(markup, {
processAttribute,
});
expect(report).toBeValid();
});
it("should report when multiple <main> are visible", () => {
expect.assertions(2);
const markup = "<main>a</main><main>b</main>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("no-multiple-main", "Multiple <main> elements present in document");
});
it("should contain documentation", () => {
expect.assertions(1);
expect(htmlvalidate.getRuleDocumentation("no-multiple-main")).toMatchSnapshot();
});
});
import { DOMReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
export default class NoMultipleMain extends Rule {
public documentation(): RuleDocumentation {
return {
description: [
"Only a single visible `<main>` element can be present at in a document at a time.",
"",
"Multiple `<main>` can be present in the DOM as long the others are hidden using the HTML5 `hidden` attribute.",
].join("\n"),
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("dom:ready", (event: DOMReadyEvent) => {
const { document } = event;
const main = document.querySelectorAll("main").filter((cur) => !cur.hasAttribute("hidden"));
main.shift(); /* ignore the first occurrence */
/* report all other occurrences */
for (const elem of main) {
this.report(elem, "Multiple <main> elements present in document");
}
});
}
}