...
 
Commits (15)
# html-validate changelog
# [2.1.0](https://gitlab.com/html-validate/html-validate/compare/v2.0.1...v2.1.0) (2019-11-21)
### Bug Fixes
- **deps:** update dependency chalk to v3 ([f84bd35](https://gitlab.com/html-validate/html-validate/commit/f84bd35b637e558cdcaf01fec9ed6ebc52d895ca))
- **rules:** wcag/h32 support custom form elements ([e00e1ed](https://gitlab.com/html-validate/html-validate/commit/e00e1ed30e714b679e161308daa07df80e89edde))
### Features
- **meta:** add method to query all tags with given property ([eb3c593](https://gitlab.com/html-validate/html-validate/commit/eb3c59343efa911e4e5ed22f4eb87408e3036325))
- **meta:** adding `form` property ([edf05b0](https://gitlab.com/html-validate/html-validate/commit/edf05b09d0600be548b4d52b79421f6d13713010))
- **meta:** allow inheritance ([5c7725d](https://gitlab.com/html-validate/html-validate/commit/5c7725d5d5062e3a55fd189ccd29712bd4cc26cd))
- **meta:** support [@form](https://gitlab.com/form) category ([66d75a8](https://gitlab.com/html-validate/html-validate/commit/66d75a86783f247c62302c431ab8ce35d22b4215))
## [2.0.1](https://gitlab.com/html-validate/html-validate/compare/v2.0.0...v2.0.1) (2019-11-19)
### Bug Fixes
......
......@@ -11,13 +11,13 @@ module.exports = function generateValidationResultsProcessor(log, validateMap) {
};
function $process() {
const oldEnabled = chalk.enabled;
chalk.enabled = false;
const oldLevel = chalk.level;
chalk.level = 0;
validateMap.forEach(validation => {
htmlvalidate = new HtmlValidate(validation.config);
validation.report = htmlvalidate.validateString(validation.markup);
validation.codeframe = codeframe(validation.report.results);
});
chalk.enabled = oldEnabled;
chalk.level = oldLevel;
}
};
......@@ -37,6 +37,7 @@ export interface MetaElement {
void: boolean;
transparent: boolean;
scriptSupporting: boolean;
form: boolean;
/* attributes */
deprecatedAttributes: string[];
......@@ -49,6 +50,9 @@ export interface MetaElement {
permittedOrder: PermittedOrder;
requiredAncestors: string[];
requiredContent: string[];
/* inheritance */
inherit?: string;
}
```
......@@ -140,6 +144,11 @@ In HTML5 both the `<script>` and `<template>` tags are considered script-support
[whatwg-scriptsupporting]: https://html.spec.whatwg.org/multipage/dom.html#script-supporting-elements-2
### `form`
Elements which are considered to be a form-element should set this flag to `true`.
In plain HTML only the `<form>` element is considered a form but when using custom components the form element might be wrapped inside and to make rules related to forms pick up the custom element this flag should be set.
## Permitted content
### `attributes`
......@@ -364,3 +373,33 @@ elements, e.g. global attributes.
]
}
```
## Inheritance
Elements can inherit from other elements using the `inherits` property.
When inheriting all properties will be duplicated to the new element.
Any new property set on the element will override the parent element.
Given the following metadata:
```js
"foo": {
"flow": true,
"transparent": true
},
"bar": {
"inherit": "foo",
"transparent": false
}
```
The final `<bar>` metadata will be merged to:
```js
"bar": {
"flow": true,
"transparent": false
}
```
Elements being inherited must be defined before the inheritor or an error will be thrown.
......@@ -1816,7 +1816,7 @@ exports[`HTML elements <footer> valid markup 1`] = `Array []`;
exports[`HTML elements <form> invalid markup 1`] = `
Array [
Object {
"errorCount": 1,
"errorCount": 2,
"filePath": "test-files/elements/form-invalid.html",
"messages": Array [
Object {
......@@ -1829,11 +1829,26 @@ Array [
"severity": 2,
"size": 4,
},
Object {
"column": 3,
"context": undefined,
"line": 8,
"message": "Element <custom-form> is not permitted as descendant of <form>",
"offset": 129,
"ruleId": "element-permitted-content",
"severity": 2,
"size": 11,
},
],
"source": "<!-- should not allow nesting -->
<form>
<form></form>
</form>
<!-- should not allow nesting with custom elements -->
<form>
<custom-form></custom-form>
</form>
",
"warningCount": 0,
},
......
......@@ -326,6 +326,7 @@
"form": {
"flow": true,
"form": true,
"attributes": {
"autocomplete": ["on", "off"],
"method": ["get", "post"],
......@@ -333,7 +334,7 @@
},
"deprecatedAttributes": ["accept"],
"permittedContent": ["@flow"],
"permittedDescendants": [{ "exclude": ["form"] }]
"permittedDescendants": [{ "exclude": ["@form"] }]
},
"frame": {
......
......@@ -144,6 +144,14 @@ const tagNames = [
describe("HTML elements", () => {
const htmlvalidate = new HtmlValidate({
extends: ["htmlvalidate:recommended"],
elements: [
"html5",
{
"custom-form": {
inherit: "form",
},
},
],
rules: {
/* allow any style of boolean attributes, some tests runs all of them */
"attribute-boolean-style": "off",
......
......@@ -5,6 +5,8 @@
"^.*$": {
"type": "object",
"properties": {
"inherit": { "type": "string" },
"embedded": { "$ref": "#/definitions/contentCategory" },
"flow": { "$ref": "#/definitions/contentCategory" },
"heading": { "$ref": "#/definitions/contentCategory" },
......@@ -21,6 +23,7 @@
"transparent": { "type": "boolean" },
"implicitClosed": { "type": "array", "contains": { "type": "string" } },
"scriptSupporting": { "type": "boolean" },
"form": { "type": "boolean" },
"deprecatedAttributes": {
"type": "array",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "html-validate",
"version": "2.0.1",
"version": "2.1.0",
"description": "html linter",
"keywords": [
"html",
......@@ -126,7 +126,7 @@
"acorn-walk": "^7.0.0",
"ajv": "^6.10.0",
"better-ajv-errors": "^0.6.2",
"chalk": "^2.4.2",
"chalk": "^3.0.0",
"deepmerge": "^4.0.0",
"eslint": "^6.0.0",
"espree": "^6.0.0",
......@@ -156,14 +156,14 @@
"@types/node": "11.15.2",
"@typescript-eslint/eslint-plugin": "2.8.0",
"@typescript-eslint/parser": "2.8.0",
"autoprefixer": "9.7.1",
"autoprefixer": "9.7.2",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
"canonical-path": "1.0.0",
"cssnano": "4.1.10",
"dgeni": "0.4.12",
"dgeni-packages": "0.28.1",
"eslint-config-prettier": "6.6.0",
"eslint-config-prettier": "6.7.0",
"eslint-config-sidvind": "1.3.2",
"eslint-plugin-array-func": "3.1.3",
"eslint-plugin-import": "2.18.2",
......@@ -190,10 +190,10 @@
"load-grunt-tasks": "5.1.0",
"minimatch": "3.0.4",
"prettier": "1.19.1",
"sass": "1.23.6",
"sass": "1.23.7",
"semantic-release": "15.13.31",
"serve-static": "1.14.1",
"strip-ansi": "5.2.0",
"strip-ansi": "6.0.0",
"ts-jest": "24.1.0",
"tslint": "5.20.1",
"tslint-config-prettier": "1.18.0",
......
......@@ -642,6 +642,7 @@ function mockEntry(stub = {}): MetaData {
void: false,
transparent: false,
scriptSupporting: false,
form: false,
},
stub
);
......
......@@ -3,6 +3,7 @@ import { Source } from "../context";
import { DOMTree } from "../dom";
import { InvalidTokenError } from "../lexer";
import "../matchers";
import { MetaTable } from "../meta";
import { Parser } from "../parser";
import { Reporter } from "../reporter";
import { Rule, RuleOptions } from "../rule";
......@@ -355,7 +356,8 @@ describe("Engine", () => {
expect(rule.init).toHaveBeenCalledWith(
parser,
reporter,
Severity.ERROR
Severity.ERROR,
expect.any(MetaTable)
);
expect(rule.setup).toHaveBeenCalledWith();
expect(rule.name).toEqual("void");
......
......@@ -386,9 +386,10 @@ export class Engine<T extends Parser = Parser> {
parser: Parser,
report: Reporter
): Rule {
const meta = this.config.getMetaTable();
const rule = this.instantiateRule(ruleId, options);
rule.name = rule.name || ruleId;
rule.init(parser, report, severity);
rule.init(parser, report, severity, meta);
/* call setup callback if present */
if (rule.setup) {
......
import { codeFrameColumns } from "@babel/code-frame";
import chalk from "chalk";
import chalk = require("chalk");
import path from "path";
import { FormatterModule } from ".";
import { Message, Result } from "../reporter";
......
......@@ -2,7 +2,7 @@ import { Severity } from "./config";
import { Token, TokenType } from "./lexer";
import "./matchers";
import { Report, Reporter } from "./reporter";
import stripAnsi from "strip-ansi";
import stripAnsi = require("strip-ansi");
let reportOk: Report;
let reportError: Report;
......
......@@ -15,6 +15,9 @@ export interface PermittedAttribute {
}
export interface MetaData {
/* special keyword to extend metadata from another entry */
inherit?: string;
/* content categories */
metadata: boolean | PropertyExpression;
flow: boolean | PropertyExpression;
......@@ -31,6 +34,7 @@ export interface MetaData {
transparent: boolean;
implicitClosed?: string[];
scriptSupporting: boolean;
form: boolean;
/* attribute */
deprecatedAttributes?: string[];
......@@ -45,6 +49,21 @@ export interface MetaData {
requiredContent?: RequiredContent;
}
export type MetaLookupableProperty =
| "metadata"
| "flow"
| "sectioning"
| "heading"
| "phrasing"
| "embedded"
| "interactive"
| "deprecated"
| "foreign"
| "void"
| "transparent"
| "scriptSupporting"
| "form";
export interface MetaElement extends MetaData {
/* filled internally for reverse lookup */
tagName: string;
......
export { MetaTable } from "./table";
export { MetaElement, MetaData, PropertyExpression } from "./element";
export {
MetaData,
MetaElement,
MetaLookupableProperty,
PropertyExpression,
} from "./element";
export { Validator } from "./validator";
......@@ -305,6 +305,90 @@ describe("MetaTable", () => {
attr: ["foo", /bar/, /baz/],
});
});
describe("inheritance", () => {
it("should be supported", () => {
const table = new MetaTable();
table.loadFromObject({
foo: mockEntry({
flow: true,
}),
bar: {
inherit: "foo",
} as MetaData,
});
const bar = table.getMetaFor("bar");
expect(bar).toEqual(
expect.objectContaining({
tagName: "bar",
flow: true,
phrasing: false,
})
);
});
it("should allow overriding", () => {
const table = new MetaTable();
table.loadFromObject({
foo: mockEntry({
flow: true,
phrasing: true,
}),
bar: {
inherit: "foo",
flow: false,
} as MetaData,
});
const bar = table.getMetaFor("bar");
expect(bar).toEqual(
expect.objectContaining({
tagName: "bar",
flow: false,
phrasing: true,
})
);
});
it("should throw error when extending missing element", () => {
const table = new MetaTable();
expect(() => {
table.loadFromObject({
foo: {
inherit: "bar",
} as MetaData,
});
}).toThrow("Element <foo> cannot inherit from <bar>: no such element");
});
});
describe("getTagsWithProperty()", () => {
it("should return list of all tags with given property enabled", () => {
expect.assertions(2);
const table = new MetaTable();
table.loadFromObject({
foo: mockEntry({
flow: true,
}),
bar: mockEntry({
flow: true,
phrasing: true,
}),
});
expect(table.getTagsWithProperty("flow")).toEqual(["foo", "bar"]);
expect(table.getTagsWithProperty("phrasing")).toEqual(["bar"]);
});
it("should return empty list if nothing matches", () => {
expect.assertions(1);
const table = new MetaTable();
table.loadFromObject({
foo: mockEntry({
flow: true,
}),
});
expect(table.getTagsWithProperty("phrasing")).toEqual([]);
});
});
});
function mockEntry(stub = {}): MetaData {
......@@ -322,6 +406,7 @@ function mockEntry(stub = {}): MetaData {
void: false,
transparent: false,
scriptSupporting: false,
form: false,
},
stub
);
......
......@@ -10,6 +10,7 @@ import {
MetaData,
MetaDataTable,
MetaElement,
MetaLookupableProperty,
PropertyExpression,
} from "./element";
import { MetaValidationError } from "./validation-error";
......@@ -119,14 +120,37 @@ export class MetaTable {
: null;
}
/**
* Find all tags which has enabled given property.
*/
public getTagsWithProperty(propName: MetaLookupableProperty): string[] {
return Object.entries(this.elements)
.filter(([, entry]) => entry[propName])
.map(([tagName]) => tagName);
}
private addEntry(tagName: string, entry: MetaData): void {
const expanded: MetaElement = Object.assign(
{
tagName,
void: false,
},
entry
) as MetaElement;
const defaultEntry = {
void: false,
};
let parent = {};
/* handle inheritance */
if (entry.inherit) {
const name = entry.inherit;
parent = this.elements[name];
if (!parent) {
throw new UserError(
`Element <${tagName}> cannot inherit from <${name}>: no such element`
);
}
delete entry.inherit;
}
/* merge all sources together */
const expanded: MetaElement = Object.assign(defaultEntry, parent, entry, {
tagName,
}) as MetaElement;
expandRegex(expanded);
this.elements[tagName] = expanded;
......
import stripAnsi from "strip-ansi";
import stripAnsi = require("strip-ansi");
import { MetaElement } from "./element";
import { MetaTable } from "./table";
import { MetaValidationError } from "./validation-error";
......
......@@ -152,6 +152,40 @@ describe("Meta validator", () => {
expect(Validator.validatePermitted(nil, rules)).toBeFalsy();
});
it("should validate @script", () => {
const table = new MetaTable();
table.loadFromObject({
nil: mockEntry({ void: true }),
scripting: mockEntry({
scriptSupporting: true,
void: true,
}),
});
const parser = new Parser(new ConfigMock(table));
const [script, nil] = parser.parseHtml(
"<scripting/><nil/>"
).root.childElements;
const rules = ["@script"];
expect(Validator.validatePermitted(script, rules)).toBeTruthy();
expect(Validator.validatePermitted(nil, rules)).toBeFalsy();
});
it("should validate @form", () => {
const table = new MetaTable();
table.loadFromObject({
nil: mockEntry({ void: true }),
form: mockEntry({
form: true,
void: true,
}),
});
const parser = new Parser(new ConfigMock(table));
const [form, nil] = parser.parseHtml("<form/><nil/>").root.childElements;
const rules = ["@form"];
expect(Validator.validatePermitted(form, rules)).toBeTruthy();
expect(Validator.validatePermitted(nil, rules)).toBeFalsy();
});
it("should validate multiple rules (OR)", () => {
const table = new MetaTable();
table.loadFromObject({
......@@ -590,6 +624,7 @@ function mockEntry(stub = {}): MetaData {
void: false,
transparent: false,
scriptSupporting: false,
form: false,
},
stub
);
......
......@@ -268,6 +268,8 @@ export class Validator {
return node.meta.interactive as boolean;
case "@script":
return node.meta.scriptSupporting;
case "@form":
return node.meta.form;
default:
throw new Error(`Invalid content category "${category}"`);
}
......
......@@ -5,6 +5,7 @@ import { Event } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { Rule, ruleDocumentationUrl } from "./rule";
import { MetaTable } from "./meta";
class MockRule extends Rule {
public setup(): void {
......@@ -15,6 +16,7 @@ class MockRule extends Rule {
describe("rule base class", () => {
let parser: Parser;
let reporter: Reporter;
let meta: MetaTable;
let rule: Rule;
let mockLocation: Location;
let mockEvent: Event;
......@@ -24,10 +26,12 @@ describe("rule base class", () => {
parser.on = jest.fn();
reporter = new Reporter();
reporter.add = jest.fn();
meta = new MetaTable();
meta.loadFromFile("../../elements/html5.json");
rule = new MockRule({});
rule.name = "mock-rule";
rule.init(parser, reporter, Severity.ERROR);
rule.init(parser, reporter, Severity.ERROR, meta);
mockLocation = { filename: "mock-file", offset: 1, line: 1, column: 2 };
mockEvent = {
location: mockLocation,
......@@ -173,6 +177,12 @@ describe("rule base class", () => {
it("documentation() should return null", () => {
expect(rule.documentation()).toBeNull();
});
it("getTagsWithProperty() should lookup properties from metadata", () => {
const spy = jest.spyOn(meta, "getTagsWithProperty");
expect(rule.getTagsWithProperty("form")).toEqual(["form"]);
expect(spy).toHaveBeenCalledWith("form");
});
});
it("ruleDocumentationUrl() should return URL to rule documentation", () => {
......
......@@ -16,6 +16,7 @@ import {
} from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { MetaTable, MetaLookupableProperty } from "./meta";
const homepage = require("../package.json").homepage;
......@@ -33,6 +34,7 @@ export type RuleConstructor = new (options: RuleOptions) => Rule;
export abstract class Rule<T = any> {
private reporter: Reporter;
private parser: Parser;
private meta: MetaTable;
private enabled: boolean; // rule enabled/disabled, irregardless of severity
private severity: number; // rule severity, 0: off, 1: warning 2: error
private event: any;
......@@ -84,6 +86,10 @@ export abstract class Rule<T = any> {
return this.enabled && this.severity >= Severity.WARN;
}
public getTagsWithProperty(propName: MetaLookupableProperty): string[] {
return this.meta.getTagsWithProperty(propName);
}
/**
* Report a new error.
*
......@@ -166,10 +172,16 @@ export abstract class Rule<T = any> {
*
* @hidden
*/
public init(parser: Parser, reporter: Reporter, severity: number): void {
public init(
parser: Parser,
reporter: Reporter,
severity: number,
meta: MetaTable
): void {
this.parser = parser;
this.reporter = reporter;
this.severity = severity;
this.meta = meta;
}
/**
......
......@@ -6,6 +6,14 @@ describe("wcag/h32", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
elements: [
"html5",
{
"my-form": {
form: true,
},
},
],
rules: { "wcag/h32": "error" },
});
});
......@@ -44,6 +52,15 @@ describe("wcag/h32", () => {
);
});
it("should support custom elements", () => {
const report = htmlvalidate.validateString("<my-form></my-form>");
expect(report).toBeInvalid();
expect(report).toHaveError(
"WCAG/H32",
"<my-form> element must have a submit button"
);
});
it("smoketest", () => {
const report = htmlvalidate.validateFile("test-files/rules/wcag/h32.html");
expect(report.results).toMatchSnapshot();
......
......@@ -17,8 +17,14 @@ class H32 extends Rule {
}
public setup(): void {
/* query all tags with form property, normally this is only the <form> tag
* but with custom element metadata other tags might be considered form
* (usually a component wrapping a <form> element) */
const formTags = this.getTagsWithProperty("form");
const formSelector = formTags.join(",");
this.on("dom:ready", (event: DOMReadyEvent) => {
const forms = event.document.getElementsByTagName("form");
const forms = event.document.querySelectorAll(formSelector);
forms.forEach((node: HtmlElement) => {
/* find submit buttons */
for (const button of node.querySelectorAll("button,input")) {
......@@ -28,7 +34,10 @@ class H32 extends Rule {
}
}
this.report(node, "<form> element must have a submit button");
this.report(
node,
`<${node.tagName}> element must have a submit button`
);
});
});
}
......
......@@ -2,3 +2,8 @@
<form>
<form></form>
</form>
<!-- should not allow nesting with custom elements -->
<form>
<custom-form></custom-form>
</form>