Commit 7f585721 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): add `minInitialRank` option to `heading-level`

fixes #132
parent 44ee751e
Pipeline #365517669 passed with stages
in 11 minutes and 39 seconds
......@@ -28,4 +28,6 @@ Array [
]
`;
exports[`docs/rules/heading-level.md inline validation: min-initial-rank 1`] = `Array []`;
exports[`docs/rules/heading-level.md inline validation: sectioning-root 1`] = `Array []`;
......@@ -5,6 +5,10 @@ markup["incorrect"] = `<h1>Heading 1</h1>
<h3>Subheading</h3>`;
markup["correct"] = `<h1>Heading 1</h1>
<h2>Subheading</h2>`;
markup["min-initial-rank"] = `<nav>
<h2>Navigation</h2>
</nav>
<h1>Heading 1</h1>`;
markup["sectioning-root"] = `<h1>Heading 1</h1>
<h2>Subheading 2</h2>
<dialog>
......@@ -27,6 +31,12 @@ describe("docs/rules/heading-level.md", () => {
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: min-initial-rank", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"heading-level":["error",{"minInitialRank":"h2"}]}});
const report = htmlvalidate.validateString(markup["min-initial-rank"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: sectioning-root", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"heading-level":"error"}});
......
......@@ -33,6 +33,7 @@ This rule takes an optional object:
```json
{
"allowMultipleH1": false,
"minInitialRank": "h1",
"sectioningRoots": ["dialog", "[role=\"dialog\"]"]
}
```
......@@ -41,6 +42,26 @@ This rule takes an optional object:
Set `allowMultipleH1` to `true` to allow multiple `<h1>` elements in a document.
### `minInitialRank`
- type: `"h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "any" | false`
- default: `"h1"`
Sets the allowed initial heading levels (inclusive).
By setting this to `h2` the document may start at `<h2>` and later being followed by `<h1>`:
<validate name="min-initial-rank" rules="heading-level" heading-level='{"minInitialRank": "h2"}'>
<nav>
<h2>Navigation</h2>
</nav>
<h1>Heading 1</h1>
</validate>
Setting this to `"any"` or `false` is equivalent to `"h6"`, i.e. effectively disabling the check for the initial heading level as all possible levels are now allowed.
Note: this does not affect sectioning roots.
Each sectioning root can either continue of the current level or restart at `<h1>`.
### `sectioningRoots`
List of selectors for elements starting new sectioning roots, that is elements with their own outlines.
......@@ -63,3 +84,7 @@ With this option the following is considered valid:
</validate>
[html5-sectioning-root]: https://html.spec.whatwg.org/multipage/sections.html#sectioning-root
## Version history
- %version% - `minInitialRank` option added.
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule heading-level should contain documentation (with minInitialRank) 1`] = `
Object {
"description": "Headings should start at <h1> and can only increase one level at a time.
The headings should form a table of contents and make sense on its own.
Under the current configuration only a single <h1> can be present at a time in the document.",
"url": "https://html-validate.org/rules/heading-level.html",
}
`;
exports[`rule heading-level should contain documentation (with multiple h1) 1`] = `
Object {
"description": "Headings must start at <h1> and can only increase one level at a time.
......
......@@ -50,7 +50,8 @@ describe("rule heading-level", () => {
it("should report error when initial heading isn't <h1>", () => {
expect.assertions(2);
const report = htmlvalidate.validateString("<h2>heading 2</h2>");
const markup = "<h2>heading 2</h2>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("heading-level", "Initial heading level must be <h1> but got <h2>");
});
......@@ -81,16 +82,16 @@ describe("rule heading-level", () => {
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>
`;
<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();
});
......@@ -98,14 +99,14 @@ describe("rule heading-level", () => {
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>
`;
<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();
});
......@@ -113,14 +114,14 @@ describe("rule heading-level", () => {
it("should not allow skipping heading levels", () => {
expect.assertions(2);
const markup = `
<h1>heading 1</h1>
<h2>heading 2</h2>
<h3>heading 2</h3>
<div role="dialog">
<h5>modal header</h5>
</div>
<h3>heading 2</h3>
`;
<h1>heading 1</h1>
<h2>heading 2</h2>
<h3>heading 2</h3>
<div role="dialog">
<h5>modal header</h5>
</div>
<h3>heading 2</h3>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
......@@ -132,10 +133,10 @@ describe("rule heading-level", () => {
it("should enforce h1 as initial heading level if sectioning root is the only content in document", () => {
expect.assertions(2);
const markup = `
<div role="dialog">
<h5>modal header</h5>
</div>
`;
<div role="dialog">
<h5>modal header</h5>
</div>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
......@@ -145,6 +146,89 @@ describe("rule heading-level", () => {
});
});
describe("minInitialRank", () => {
it("configured with h2 should allow initial h2", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "h2" }] },
});
const markup = `
<h2>heading 2</h2>
<h3>heading 2</h3>
<h1>heading 2</h1>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("configured with h2 should not allow initial h3", () => {
expect.assertions(2);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "h2" }] },
});
const markup = `
<h3>heading 3</h3>
<h1>heading 3</h1>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"heading-level",
"Initial heading level must be <h2> or higher rank but got <h3>"
);
});
it("should allow continuous sectioning root", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "h2" }] },
});
const markup = `
<h2>heading 2</h2>
<div role="dialog">
<h3>modal header</h3>
</div>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should allow sectioning root with initial heading level", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "h2" }] },
});
const markup = `
<h2>heading 2</h2>
<div role="dialog">
<h1>modal header</h1>
</div>
`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it('"any" should be equivalent to "h6"', () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "any" }] },
});
const markup = "<h6></h6>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it('false should be equivalent to "h6"', () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: false }] },
});
const markup = "<h6></h6>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
it("smoketest", () => {
expect.assertions(1);
const report = htmlvalidate.validateFile("test-files/rules/heading-level.html");
......@@ -166,4 +250,12 @@ describe("rule heading-level", () => {
});
expect(htmlvalidate.getRuleDocumentation("heading-level")).toMatchSnapshot();
});
it("should contain documentation (with minInitialRank)", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({
rules: { "heading-level": ["error", { minInitialRank: "h2" }] },
});
expect(htmlvalidate.getRuleDocumentation("heading-level")).toMatchSnapshot();
});
});
......@@ -7,6 +7,7 @@ import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../
interface RuleOptions {
allowMultipleH1: boolean;
minInitialRank: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "any" | false;
sectioningRoots: string[];
}
......@@ -18,6 +19,7 @@ interface SectioningRoot {
const defaults: RuleOptions = {
allowMultipleH1: false,
minInitialRank: "h1",
sectioningRoots: ["dialog", '[role="dialog"]'],
};
......@@ -35,12 +37,26 @@ function extractLevel(node: HtmlElement): number | null {
}
}
function parseMaxInitial(value: string | false): number {
if (value === false || value === "any") {
return 6;
}
const match = value.match(/^h(\d)$/);
/* istanbul ignore next: should never happen, schema validation should catch invalid values */
if (!match) {
return 1;
}
return parseInt(match[1], 10);
}
export default class HeadingLevel extends Rule<void, RuleOptions> {
private minInitialRank: number;
private sectionRoots: Pattern[];
private stack: SectioningRoot[] = [];
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.minInitialRank = parseMaxInitial(this.options.minInitialRank);
this.sectionRoots = this.options.sectioningRoots.map((it) => new Pattern(it));
/* add a global sectioning root used by default */
......@@ -56,6 +72,9 @@ export default class HeadingLevel extends Rule<void, RuleOptions> {
allowMultipleH1: {
type: "boolean",
},
minInitialRank: {
enum: ["h1", "h2", "h3", "h4", "h5", "h6", "any", false],
},
sectioningRoots: {
items: {
type: "string",
......@@ -67,7 +86,8 @@ export default class HeadingLevel extends Rule<void, RuleOptions> {
public documentation(): RuleDocumentation {
const text: string[] = [];
text.push("Headings must start at <h1> and can only increase one level at a time.");
const modality = this.minInitialRank > 1 ? "should" : "must";
text.push(`Headings ${modality} start at <h1> and can only increase one level at a time.`);
text.push("The headings should form a table of contents and make sense on its own.");
if (!this.options.allowMultipleH1) {
text.push("");
......@@ -125,14 +145,27 @@ export default class HeadingLevel extends Rule<void, RuleOptions> {
level: number
): void {
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 {
this.checkInitialLevel(event, location, level, expected);
}
/* check if the new level is the expected one (headings with higher ranks
* are skipped already) */
if (level === expected) {
return;
}
/* if this is the initial heading of the document it is compared to the
* minimal allowed (default h1) */
const isInitial = this.stack.length === 1 && expected === 1;
if (isInitial && level <= this.minInitialRank) {
return;
}
/* if we reach this far the heading level is not accepted */
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 {
this.checkInitialLevel(event, location, level, expected);
}
}
......@@ -143,7 +176,10 @@ export default class HeadingLevel extends Rule<void, RuleOptions> {
expected: number
): void {
if (this.stack.length === 1) {
const msg = `Initial heading level must be <h${expected}> but got <h${level}>`;
const msg =
this.minInitialRank > 1
? `Initial heading level must be <h${this.minInitialRank}> or higher rank but got <h${level}>`
: `Initial heading level must be <h${expected}> but got <h${level}>`;
this.report(event.target, msg, location);
} else {
const prevRoot = this.getPrevRoot();
......
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