Commit 664dead7 authored by David Sveningsson's avatar David Sveningsson

feat(rules): new rule element-required-content

parent e177a6f7
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/element-required-content.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/element-required-content.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 2,
"filePath": "inline",
"messages": Array [
Object {
"column": 2,
"context": Object {
"missing": "head",
"node": "html",
},
"line": 1,
"message": "<html> element must have <head> as content",
"offset": 1,
"ruleId": "element-required-content",
"severity": 2,
"size": 4,
},
Object {
"column": 2,
"context": Object {
"missing": "body",
"node": "html",
},
"line": 1,
"message": "<html> element must have <body> as content",
"offset": 1,
"ruleId": "element-required-content",
"severity": 2,
"size": 4,
},
],
"source": "<html>
</html>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<html>
</html>`;
markup["correct"] = `<html>
<head></head>
<body></body>
</html>`;
describe("docs/rules/element-required-content.md", () => {
it("inline validation: incorrect", () => {
const htmlvalidate = new HtmlValidate({"rules":{"element-required-content":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
const htmlvalidate = new HtmlValidate({"rules":{"element-required-content":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
@ngdoc rule
@module rules
@name element-required-content
@category content-model
@summary Ensure required elements are present
@description
# Ensure required elements are present (`element-required-content`)
Some elements has requirements where certain child elements has to be present.
The requirements comes from the [element metadata](/usage/elements.html):
```js
{
"my-element": {
"requiredContent": ["my-other-element"]
}
}
```
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="element-required-content">
<html>
</html>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="element-required-content">
<html>
<head></head>
<body></body>
</html>
</validate>
......@@ -47,6 +47,7 @@ export interface MetaElement {
permittedDescendants: Permitted;
permittedOrder: PermittedOrder;
requiredAncestors: string[];
requiredContent: string[];
}
```
......@@ -320,6 +321,27 @@ present.
This is used by
[element-permitted-content](/rules/element-permitted-content.html) rule.
### `requiredContent`
Requires certain content in an element.
Some elements has requirements of what content must be present. For instance,
the `<head>` element requires a `<title>` element.
`requiredContent` is a list of tagnames which must be present as a direct
descentant of the element.
```js
"head": {
"requiredContent": [
"title"
]
}
```
This is used by [element-required-content](/rules/element-required-content.html)
rule.
## Global element
The special `*` element can be used to assign global metadata applying to all
......
......@@ -2253,9 +2253,9 @@ Array [
Object {
"column": 4,
"context": undefined,
"line": 18,
"line": 19,
"message": "Element <base> can only appear once under <head>",
"offset": 267,
"offset": 282,
"ruleId": "element-permitted-occurrences",
"severity": 2,
"size": 4,
......@@ -2263,9 +2263,9 @@ Array [
Object {
"column": 4,
"context": undefined,
"line": 26,
"line": 28,
"message": "Element <title> can only appear once under <head>",
"offset": 387,
"offset": 417,
"ruleId": "element-permitted-occurrences",
"severity": 2,
"size": 5,
......@@ -2282,6 +2282,7 @@ Array [
<div></div>
<span></span>
</head>
<body></body>
</html>
<!-- should only allow <base> once -->
......@@ -2290,6 +2291,7 @@ Array [
<base>
<base>
</head>
<body></body>
</html>
<!-- should only allow <titl> once -->
......@@ -2298,6 +2300,7 @@ Array [
<title>lorem ipsum</title>
<title>lorem ipsum</title>
</head>
<body></body>
</html>
",
"warningCount": 0,
......@@ -2428,7 +2431,7 @@ exports[`HTML elements <hr> valid markup 1`] = `Array []`;
exports[`HTML elements <html> invalid markup 1`] = `
Array [
Object {
"errorCount": 4,
"errorCount": 6,
"filePath": "test-files/elements/html-invalid.html",
"messages": Array [
Object {
......@@ -2444,12 +2447,38 @@ Array [
"severity": 2,
"size": 4,
},
Object {
"column": 2,
"context": Object {
"missing": "head",
"node": "html",
},
"line": 8,
"message": "<html> element must have <head> as content",
"offset": 118,
"ruleId": "element-required-content",
"severity": 2,
"size": 4,
},
Object {
"column": 2,
"context": Object {
"missing": "body",
"node": "html",
},
"line": 8,
"message": "<html> element must have <body> as content",
"offset": 118,
"ruleId": "element-required-content",
"severity": 2,
"size": 4,
},
Object {
"column": 3,
"context": undefined,
"line": 8,
"line": 14,
"message": "Element <head> can only appear once under <html>",
"offset": 119,
"offset": 217,
"ruleId": "element-permitted-occurrences",
"severity": 2,
"size": 4,
......@@ -2457,9 +2486,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 14,
"line": 22,
"message": "Element <body> can only appear once under <html>",
"offset": 215,
"offset": 343,
"ruleId": "element-permitted-occurrences",
"severity": 2,
"size": 4,
......@@ -2467,9 +2496,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 20,
"line": 28,
"message": "Element <head> must be used before <body> in this context",
"offset": 314,
"offset": 442,
"ruleId": "element-permitted-order",
"severity": 2,
"size": 4,
......@@ -2477,16 +2506,24 @@ Array [
],
"source": "<!-- should require lang -->
<html>
<head></head>
<body></body>
</html>
<!-- should require <head> and <body> -->
<html lang=\\"en\\">
</html>
<!-- should not allow multiple head -->
<html lang=\\"en\\">
<head></head>
<head></head>
<body></body>
</html>
<!-- should not allow multiple body -->
<html lang=\\"en\\">
<head></head>
<body></body>
<body></body>
</html>
......
......@@ -411,7 +411,8 @@
"deprecatedAttributes": ["version"],
"permittedContent": ["head?", "body?"],
"permittedOrder": ["head", "body"],
"requiredAttributes": ["lang"]
"requiredAttributes": ["lang"],
"requiredContent": ["head", "body"]
},
"i": {
......
......@@ -34,7 +34,8 @@
"permittedContent": { "$ref": "#/definitions/Permitted" },
"permittedDescendants": { "$ref": "#/definitions/Permitted" },
"permittedOrder": { "$ref": "#/definitions/PermittedOrder" },
"requiredAncestors": { "$ref": "#/definitions/RequiredAncestors" }
"requiredAncestors": { "$ref": "#/definitions/RequiredAncestors" },
"requiredContent": { "$ref": "#/definitions/RequiredContent" }
},
"additionalProperties": false
}
......@@ -131,6 +132,13 @@
"items": {
"type": "string"
}
},
"RequiredContent": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
......@@ -21,6 +21,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -110,6 +111,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -230,6 +232,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -316,6 +319,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -406,6 +410,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -495,6 +500,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -568,6 +574,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -641,6 +648,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......@@ -691,6 +699,7 @@ Object {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......
......@@ -14,6 +14,7 @@ module.exports = {
"element-permitted-occurrences": "error",
"element-permitted-order": "error",
"element-required-attributes": "error",
"element-required-content": "error",
"empty-heading": "error",
"empty-title": "error",
"long-title": "error",
......
......@@ -119,6 +119,11 @@ export class HtmlElement extends DOMNode {
return null;
}
/**
* Tests if this element has given tagname.
*
* If passing "*" this test will pass if any tagname is set.
*/
public is(tagName: string): boolean {
return (this.tagName && tagName === "*") || this.tagName === tagName;
}
......
......@@ -8,6 +8,7 @@ export type Permitted = PermittedEntry[];
export type PermittedOrder = string[];
export type RequiredAncestors = string[];
export type RequiredContent = string[];
export interface PermittedAttribute {
[key: string]: Array<string | RegExp>;
......@@ -40,6 +41,7 @@ export interface MetaData {
permittedDescendants?: Permitted;
permittedOrder?: PermittedOrder;
requiredAncestors?: RequiredAncestors;
requiredContent?: RequiredContent;
}
export interface MetaElement extends MetaData {
......
......@@ -462,6 +462,29 @@ describe("Meta validator", () => {
});
});
describe("validateRequiredContent()", () => {
let parser: Parser;
beforeAll(() => {
parser = new Parser(Config.empty());
});
it("should match if no rule is present", () => {
const node = parser.parseHtml("<div></div>").querySelector("div");
expect(Validator.validateRequiredContent(node, undefined)).toEqual([]);
expect(Validator.validateRequiredContent(node, [])).toEqual([]);
});
it("should return missing content", () => {
const node = parser
.parseHtml("<div><foo></foo></div>")
.querySelector("div");
expect(
Validator.validateRequiredContent(node, ["foo", "bar", "baz"])
).toEqual(["bar", "baz"]);
});
});
describe("validateAttribute()", () => {
it("should match if no rule is present", () => {
const rules = {};
......
......@@ -6,6 +6,7 @@ import {
PermittedGroup,
PermittedOrder,
RequiredAncestors,
RequiredContent,
} from "./element";
const allowedKeys = ["exclude"];
......@@ -135,6 +136,30 @@ export class Validator {
return rules.some(rule => node.closest(rule));
}
/**
* Validate element required content.
*
* Check if an element has the required set of elements. At least one of the
* selectors must match.
*
* Returns [] when valid or a list of tagNames missing as content.
*/
public static validateRequiredContent(
node: HtmlElement,
rules: RequiredContent
): string[] {
if (!rules || rules.length === 0) {
return [];
}
return rules.filter(tagName => {
const haveMatchingChild = node.childElements.some(child =>
child.is(tagName)
);
return !haveMatchingChild;
});
}
/**
* Test if an attribute has an allowed value and/or format.
*
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule element-required-content should contain contextual documentation 1`] = `
Object {
"description": "The <my-element element requires a <my-other-element> to be present as content.",
"url": "https://html-validate.org/rules/element-required-content.html",
}
`;
exports[`rule element-required-content should contain documentation 1`] = `
Object {
"description": "Some elements has requirements on content that must be present.",
"url": "https://html-validate.org/rules/element-required-content.html",
}
`;
import HtmlValidate from "../htmlvalidate";
import "../matchers";
describe("rule element-required-content", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "element-required-content": "error" },
});
});
it("should not report error when element has all required content", () => {
const report = htmlvalidate.validateString(
"<html><head></head><body></body></html>"
);
expect(report).toBeValid();
});
it("should report error when element is missing required content", () => {
const report = htmlvalidate.validateString("<html><body></body></html>");
expect(report).toBeInvalid();
expect(report).toHaveErrors([
[
"element-required-content",
"<html> element must have <head> as content",
],
]);
});
it("should report all errors when element is missing multiple content", () => {
const report = htmlvalidate.validateString("<html></html>");
expect(report).toBeInvalid();
expect(report).toHaveErrors([
[
"element-required-content",
"<html> element must have <head> as content",
],
[
"element-required-content",
"<html> element must have <body> as content",
],
]);
});
it("should contain documentation", () => {
expect(
htmlvalidate.getRuleDocumentation("element-required-content")
).toMatchSnapshot();
});
it("should contain contextual documentation", () => {
htmlvalidate = new HtmlValidate({
rules: { "element-required-content": "error" },
});
expect(
htmlvalidate.getRuleDocumentation("element-required-content", null, {
node: "my-element",
missing: "my-other-element",
})
).toMatchSnapshot();
});
});
import { HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { Validator } from "../meta";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
interface Context {
node: string;
missing: string;
}
class ElementRequiredContent extends Rule<Context> {
public documentation(context: Context): RuleDocumentation {
if (context) {
return {
description: `The <${context.node} element requires a <${context.missing}> to be present as content.`,
url: ruleDocumentationUrl(__filename),
};
} else {
return {
description:
"Some elements has requirements on content that must be present.",
url: ruleDocumentationUrl(__filename),
};
}
}
public setup(): void {
this.on("dom:ready", (event: DOMReadyEvent) => {
const doc = event.document;
doc.visitDepthFirst((node: HtmlElement) => {
/* if element doesn't have metadata (unknown element) skip checking
* required content */
if (!node.meta) {
return;
}
const rules = node.meta.requiredContent;
for (const missing of Validator.validateRequiredContent(node, rules)) {
const context: Context = {
node: node.tagName,
missing,
};
this.report(
node,
`<${node.tagName}> element must have <${missing}> as content`,
null,
context
);
}
});
});
}
}
module.exports = ElementRequiredContent;
<!-- should be allowed under <html> -->
<html lang="en">
<head></head>
<body></body>
</html>
......@@ -9,6 +9,7 @@
<div></div>
<span></span>
</head>
<body></body>
</html>
<!-- should only allow <base> once -->
......@@ -17,6 +18,7 @@
<base>
<base>
</head>
<body></body>
</html>
<!-- should only allow <titl> once -->
......@@ -25,4 +27,5 @@
<title>lorem ipsum</title>
<title>lorem ipsum</title>
</head>
<body></body>
</html>
<!-- should be allowed under <html> -->
<html lang="en">