Commit 8149cc66 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): `heading-level` supports sectioning roots

Currently only `dialog` and `role="dialog"` creates a new sectioning root.

fixes #92
parent 66f27ec7
Pipeline #244988911 passed with stages
in 10 minutes and 55 seconds
......@@ -26,3 +26,5 @@ Array [
},
]
`;
exports[`docs/rules/heading-level.md inline validation: sectioning-root 1`] = `Array []`;
......@@ -5,6 +5,14 @@ markup["incorrect"] = `<h1>Heading 1</h1>
<h3>Subheading</h3>`;
markup["correct"] = `<h1>Heading 1</h1>
<h2>Subheading</h2>`;
markup["sectioning-root"] = `<h1>Heading 1</h1>
<h2>Subheading 2</h2>
<dialog>
<!-- new sectioning root, heading level can restart at h1 -->
<h1>Dialog header</h1>
</dialog>
<!-- after dialog the level is restored -->
<h3>Subheading 3</h2>`;
describe("docs/rules/heading-level.md", () => {
it("inline validation: incorrect", () => {
......@@ -19,4 +27,10 @@ describe("docs/rules/heading-level.md", () => {
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: sectioning-root", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"heading-level":"error"}});
const report = htmlvalidate.validateString(markup["sectioning-root"]);
expect(report.results).toMatchSnapshot();
});
});
......@@ -2,10 +2,10 @@
docType: rule
name: heading-level
category: document
summary: Require headings to start at h1 and be sequential
summary: Require headings to start at h1 and increment by one
---
# heading level (`heading-level`)
# Require headings to start at h1 and increment by one (`heading-level`)
Validates heading level increments and order. Headings must start at `h1` and
can only increase one level at a time.
......@@ -32,10 +32,34 @@ This rule takes an optional object:
```json
{
"allowMultipleH1": false
"allowMultipleH1": false,
"sectioningRoots": ["dialog", "[role=\"dialog\"]"]
}
```
### AllowMultipleH1
### `allowMultipleH1`
Set `allowMultipleH1` to `true` to allow multiple `<h1>` elements in a document.
### `sectioningRoots`
List of selectors for elements starting new sectioning roots, that is elements with their own outlines.
When a new sectioning root is found the heading level may restart at `<h1>`.
The previous heading level will be restored after the sectioning root is closed by a matching end tag.
Note that the default value does not include all elements considered by HTML5 to be [sectioning roots][html5-sectioning-root] because browsers and tools does not yet implement outline algorithms specified in the standard.
With this option the following is considered valid:
<validate name="sectioning-root" rules="heading-level">
<h1>Heading 1</h1>
<h2>Subheading 2</h2>
<dialog>
<!-- new sectioning root, heading level can restart at h1 -->
<h1>Dialog header</h1>
</dialog>
<!-- after dialog the level is restored -->
<h3>Subheading 3</h2>
</validate>
[html5-sectioning-root]: https://html.spec.whatwg.org/multipage/sections.html#sectioning-root
......@@ -5,5 +5,6 @@ export { DOMTokenList } from "./domtokenlist";
export { DOMTree } from "./domtree";
export { DynamicValue } from "./dynamic-value";
export { NodeType } from "./nodetype";
export { Selector, Pattern } from "./selector";
export { TextNode } from "./text";
export { DOMNodeCache } from "./cache";
......@@ -82,7 +82,7 @@ class PseudoClassMatcher extends Matcher {
}
}
class Pattern {
export class Pattern {
public readonly combinator: Combinator;
public readonly tagName: string;
private readonly selector: string;
......
......@@ -11,7 +11,7 @@ describe("rule heading-level", () => {
});
});
it("should not report error for non-headings>", () => {
it("should not report error for non-headings", () => {
expect.assertions(1);
const report = htmlvalidate.validateString("<p>lorem ipsum</p>");
expect(report).toBeValid();
......@@ -25,9 +25,15 @@ describe("rule heading-level", () => {
it("should not report error when <h3> is followed by <h2>", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
"<h1>heading 1</h1><h2>heading 2</h2><h3>heading 3</h3><h2>heading 4</h2>"
);
const markup = "<h1>heading 1</h1><h2>heading 2</h2><h3>heading 3</h3><h2>heading 4</h2>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when root element is closed unexpectedly", () => {
expect.assertions(1);
const markup = "</div><h1>lorem ipsum</h1>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
......@@ -57,7 +63,7 @@ describe("rule heading-level", () => {
it("should not report error when multiple <h1> are used but allowed via option", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { allowMultipleH1: true }] },
});
const report = htmlvalidate.validateString("<h1>heading 1</h1><h1>heading 1</h1>");
......@@ -70,6 +76,40 @@ describe("rule heading-level", () => {
expect(report).toBeValid();
});
describe("sectioning roots", () => {
it("should allow restarting with <h1>", () => {
expect.assertions(1);
const markup = `
<h1>heading 1</h1>
<h2>heading 2</h2>
<h3>heading 2</h3>
<div role="dialog">
<!-- heading level is restarted at <h1> -->
<h1>modal header</h1>
</div>
<!-- this <h3> should valid because it is relative to the <h3> above the dialog -->
<h3>heading 2</h3>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should allow continuous headings", () => {
expect.assertions(1);
const markup = `
<h1>heading 1</h1>
<h2>heading 2</h2>
<h3>heading 2</h3>
<div role="dialog">
<h4>modal header</h4>
</div>
<h3>heading 2</h3>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
it("smoketest", () => {
expect.assertions(1);
const report = htmlvalidate.validateFile("test-files/rules/heading-level.html");
......@@ -78,7 +118,7 @@ describe("rule heading-level", () => {
it("should contain documentation (without multiple h1)", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { allowMultipleH1: false }] },
});
expect(htmlvalidate.getRuleDocumentation("heading-level")).toMatchSnapshot();
......@@ -86,7 +126,7 @@ describe("rule heading-level", () => {
it("should contain documentation (with multiple h1)", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { allowMultipleH1: true }] },
});
expect(htmlvalidate.getRuleDocumentation("heading-level")).toMatchSnapshot();
......
import { sliceLocation } from "../context";
import { HtmlElement } from "../dom";
import { TagStartEvent } from "../event";
import { HtmlElement, Pattern } from "../dom";
import { DOMInternalID } from "../dom/domnode";
import { TagCloseEvent, TagReadyEvent, TagStartEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
interface Options {
allowMultipleH1: boolean;
sectioningRoots: string[];
}
interface SectioningRoot {
node: DOMInternalID | null;
current: number;
h1Count: number;
}
const defaults: Options = {
allowMultipleH1: false,
sectioningRoots: ["dialog", '[role="dialog"]'],
};
function isRelevant(event: TagStartEvent): boolean {
const node = event.target;
return Boolean(node.meta && !!node.meta.heading);
return Boolean(node.meta && node.meta.heading);
}
function extractLevel(node: HtmlElement): number | null {
const match = node.tagName.match(/^[hH](\d)$/);
return match ? parseInt(match[1], 10) : null;
if (match) {
return parseInt(match[1], 10);
} else {
return null;
}
}
export default class HeadingLevel extends Rule<void, Options> {
private sectionRoots: Pattern[];
private stack: SectioningRoot[] = [];
public constructor(options: Partial<Options>) {
super({ ...defaults, ...options });
this.sectionRoots = this.options.sectioningRoots.map((it) => new Pattern(it));
/* add a global sectioning root used by default */
this.stack.push({
node: null,
current: 0,
h1Count: 0,
});
}
public documentation(): RuleDocumentation {
......@@ -43,44 +67,84 @@ export default class HeadingLevel extends Rule<void, Options> {
}
public setup(): void {
let current = 0;
let h1Count = 0;
this.on("tag:start", isRelevant, (event: TagStartEvent) => {
/* extract heading level from tagName */
const level = extractLevel(event.target);
if (!level) return;
/* do not allow multiple h1 */
if (!this.options.allowMultipleH1 && level === 1) {
if (h1Count >= 1) {
const location = sliceLocation(event.location, 1);
this.report(event.target, `Multiple <h1> are not allowed`, location);
return;
}
h1Count++;
}
this.on("tag:start", isRelevant, (event: TagStartEvent) => this.onTagStart(event));
this.on("tag:ready", (event: TagReadyEvent) => this.onTagReady(event));
this.on("tag:close", (event: TagCloseEvent) => this.onTagClose(event));
}
private onTagStart(event: TagStartEvent): void {
/* extract heading level from tagName (e.g "h1" -> 1)*/
const level = extractLevel(event.target);
if (!level) return;
/* allow same level or decreasing to any level (e.g. from h4 to h2) */
if (level <= current) {
current = level;
/* fetch the current sectioning root */
const root = this.getCurrentRoot();
/* do not allow multiple h1 */
if (!this.options.allowMultipleH1 && level === 1) {
if (root.h1Count >= 1) {
const location = sliceLocation(event.location, 1);
this.report(event.target, `Multiple <h1> are not allowed`, location);
return;
}
root.h1Count++;
}
/* validate heading level was only incremented by one */
const expected = current + 1;
if (level !== expected) {
const location = sliceLocation(event.location, 1);
if (current > 0) {
const msg = `Heading level can only increase by one, expected <h${expected}> but got <h${level}>`;
this.report(event.target, msg, location);
} else {
const msg = `Initial heading level must be <h${expected}> but got <h${level}>`;
this.report(event.target, msg, location);
}
/* allow same level or decreasing to any level (e.g. from h4 to h2) */
if (level <= root.current) {
root.current = level;
return;
}
/* validate heading level was only incremented by one */
const expected = root.current + 1;
if (level !== expected) {
const location = sliceLocation(event.location, 1);
if (root.current > 0) {
const msg = `Heading level can only increase by one, expected <h${expected}> but got <h${level}>`;
this.report(event.target, msg, location);
} else if (this.stack.length === 1) {
const msg = `Initial heading level must be <h${expected}> but got <h${level}>`;
this.report(event.target, msg, location);
}
}
current = level;
});
root.current = level;
}
/**
* Check if the current element is a sectioning root and push a new root entry
* on the stack if it is.
*/
private onTagReady(event: TagReadyEvent): void {
const { target } = event;
if (this.isSectioningRoot(target)) {
this.stack.push({
node: target.unique,
current: 0,
h1Count: 0,
});
}
}
/**
* Check if the current element being closed is the element which opened the
* current sectioning root, in which case the entry is popped from the stack.
*/
private onTagClose(event: TagCloseEvent): void {
const { previous: target } = event;
const root = this.getCurrentRoot();
if (target.unique !== root.node) {
return;
}
this.stack.pop();
}
private getCurrentRoot(): SectioningRoot {
return this.stack[this.stack.length - 1];
}
private isSectioningRoot(node: HtmlElement): boolean {
return this.sectionRoots.some((it) => it.match(node));
}
}
Markdown is supported
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