Commits (21)
# html-validate changelog
## [4.4.0](https://gitlab.com/html-validate/html-validate/compare/v4.3.0...v4.4.0) (2021-01-31)
### Features
- **events:** new event `tag:ready` emitted when start tag is parsed ([cfbf3dc](https://gitlab.com/html-validate/html-validate/commit/cfbf3dce948428dc3756ef60bba0a8968fbe089e))
- **events:** rename `tag:open` and `tag:close` to `tag:start` and `tag:end` ([7a2150f](https://gitlab.com/html-validate/html-validate/commit/7a2150f1f0b51f29bddeb782af2306de786f9529))
- **rules:** `heading-level` supports sectioning roots ([8149cc6](https://gitlab.com/html-validate/html-validate/commit/8149cc66e2e1fd66fc058157bda0157e271f8c96)), closes [#92](https://gitlab.com/html-validate/html-validate/issues/92)
### Bug Fixes
- **rules:** better error message for `heading-level` ([0871706](https://gitlab.com/html-validate/html-validate/commit/08717063a1b4b6f5eb88fb77cef5f5938c10e967))
### Dependency upgrades
- **deps:** update dependency @sidvind/better-ajv-errors to ^0.8.0 ([f317223](https://gitlab.com/html-validate/html-validate/commit/f31722364815f9001935330f6596df4bbb3a7204))
## [4.3.0](https://gitlab.com/html-validate/html-validate/compare/v4.2.0...v4.3.0) (2021-01-19)
### Features
......
......@@ -5,7 +5,9 @@ title: Events
# Events
## `config:ready`
## Engine
### `config:ready`
```typescript
{
......@@ -16,7 +18,9 @@ title: Events
Emitted after after configuration is ready but before DOM is initialized.
## `dom:load`
## Document
### `dom:load`
```typescript
{
......@@ -26,7 +30,7 @@ Emitted after after configuration is ready but before DOM is initialized.
Emitted after initialization but before tokenization and parsing occurs. Can be
used to initialize state in rules.
## `dom:ready`
### `dom:ready`
```typescript
{
......@@ -36,7 +40,7 @@ used to initialize state in rules.
Emitted after the parsing has finished loading the DOM tree.
## `doctype`
### `doctype`
```typescript
{
......@@ -52,7 +56,25 @@ Emitted when a doctype is encountered. `value` is the doctype (without
`location` refers to the doctype opening tag and `valueLocation` to the value
(as described above)
## `tag:open`
## DOM Nodes
```plaintext
attr attr
tag:start | tag:ready tag:start | tag:ready
| | / | | /
v vv v vv
<div class="foobar"> <input class="foobar">
.. ^
</div> \
^ element:ready
|\
| element:ready (tag:end not emitted)
tag:end
```
### `tag:start`
- Deprecated alias: `tag:open`
```typescript
{
......@@ -60,11 +82,15 @@ Emitted when a doctype is encountered. `value` is the doctype (without
}
```
Emitted when an opening element is parsed: `<div>`. `target` will be newly
created Node. The element will not have its attribute nor children yet. Use
`element:ready` to wait for the element to be complete.
Emitted when a start tag is parsed: `<div>`.
## `tag:close`
`target` will be newly created element.
The element will not have its attribute nor children yet.
Use `tag:ready` (all attributes parsed) or `element:ready` (all children parsed) if you need to wait for element to be ready.
### `tag:end`
- Deprecated alias: `tag:close`
```typescript
{
......@@ -73,11 +99,25 @@ created Node. The element will not have its attribute nor children yet. Use
}
```
Emitted when a closing element is parsed: `</div>`. `target` refers to
the close-tag itself and `previous` is the current active element
about to be closed.
Emitted when an end tag is parsed: `</div>`.
It is similar to `element:ready` but will not be emitted for `void` elements.
`target` refers to the close-tag itself and `previous` is the current active element about to be closed.
### `tag:ready`
```typescript
{
target: Node,
}
```
Emitted when a start tag is finished parsing (i.e. the node and all attributes are consumed by the parser).
`target` will be the element.
The children will not yet be parsed.
## `element:ready`
### `element:ready`
```typescript
{
......@@ -86,9 +126,11 @@ about to be closed.
```
Emitted when an element is fully constructed (including its children).
It is similar to `tag:end` but will be emitted for `void` elements as well.
`target` will be the element.
## `attr`
### `attr`
```typescript
{
......@@ -104,14 +146,13 @@ Emitted when an element is fully constructed (including its children).
Emitted when an element attribute is parsed: `<div foo="bar">`.
Target node will not have been updated with the new attribute yet
(e.g. `node.getAttribute(...)` will return `undefined` or a previous
value).
Target node will not have been updated with the new attribute yet (e.g. `node.getAttribute(...)` will return `undefined` or a previous value).
`originalAttribute` is set when a transformer has modified the attribute and contains the original attribute name, e.g. `ng-class` or `v-bind:class`.
`originalAttribute` is set when a transformer has modified the attribute and
contains the original attribute name, e.g. `ng-class` or `v-bind:class`.
## Misc
## `whitespace`
### `whitespace`
```typescript
{
......@@ -121,7 +162,7 @@ contains the original attribute name, e.g. `ng-class` or `v-bind:class`.
Emitted when inter-element, leading and trailing whitespace is parsed.
## `conditional`
### `conditional`
```typescript
{
......
......@@ -12,7 +12,7 @@ Array [
"column": 2,
"context": undefined,
"line": 2,
"message": "Heading level can only increase by one, expected h2",
"message": "Heading level can only increase by one, expected <h2> but got <h3>",
"offset": 20,
"ruleId": "heading-level",
"selector": "h3",
......@@ -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
This diff is collapsed.
{
"name": "html-validate",
"version": "4.3.0",
"version": "4.4.0",
"description": "html linter",
"keywords": [
"html",
......@@ -93,7 +93,7 @@
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@html-validate/stylish": "1.0.0",
"@sidvind/better-ajv-errors": "^0.6.10",
"@sidvind/better-ajv-errors": "^0.8.0",
"acorn-walk": "^8.0.0",
"ajv": "^7.0.0",
"chalk": "^4.0.0",
......@@ -112,9 +112,9 @@
"@html-validate/eslint-config": "3.1.0",
"@html-validate/eslint-config-jest": "3.0.0",
"@html-validate/eslint-config-typescript": "3.0.0",
"@html-validate/jest-config": "1.2.3",
"@html-validate/jest-config": "1.2.4",
"@html-validate/prettier-config": "1.1.0",
"@html-validate/semantic-release-config": "1.2.4",
"@html-validate/semantic-release-config": "1.2.6",
"@lodder/grunt-postcss": "3.0.0",
"@types/babel__code-frame": "7.0.2",
"@types/estree": "0.0.46",
......@@ -125,7 +125,7 @@
"@types/minimist": "1.2.1",
"@types/node": "11.15.44",
"@types/prompts": "2.0.9",
"autoprefixer": "10.2.1",
"autoprefixer": "10.2.4",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
"canonical-path": "1.0.0",
......@@ -151,18 +151,18 @@
"jquery": "3.5.1",
"lint-staged": "10.5.3",
"load-grunt-tasks": "5.1.0",
"marked": "1.2.7",
"marked": "1.2.8",
"minimatch": "3.0.4",
"npm-pkg-lint": "1.3.0",
"postcss": "8.2.4",
"prettier": "2.2.1",
"pretty-format": "26.6.2",
"sass": "1.32.4",
"semantic-release": "17.3.3",
"sass": "1.32.5",
"semantic-release": "17.3.7",
"serve-static": "1.14.1",
"stringmap": "0.2.2",
"strip-ansi": "6.0.0",
"ts-jest": "26.4.4",
"ts-jest": "26.5.0",
"typescript": "4.1.3"
},
"engines": {
......
......@@ -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;
......
......@@ -296,14 +296,44 @@ describe("Engine", () => {
expect(report).toBeValid();
});
it('"disable-next" should disable rule once', () => {
expect.assertions(2);
const source: Source[] = [
inline("<!-- [html-validate-disable-next close-order] --><p></i><p></i>"),
];
const report = engine.lint(source);
expect(report).toBeInvalid();
expect(report).toHaveError("close-order", expect.any(String));
describe('"disable-next"', () => {
it("should disable next error on element", () => {
expect.assertions(1);
const markup = `
<!-- [html-validate-disable-next void-style] -->
<input type="hidden" />
`;
const source: Source[] = [inline(markup)];
const report = engine.lint(source);
expect(report).toBeValid();
});
it("should disable next error once", () => {
expect.assertions(2);
const markup = `
<!-- [html-validate-disable-next void-style] -->
<input type="hidden" />
<input type="hidden" />
`;
const source: Source[] = [inline(markup)];
const report = engine.lint(source);
expect(report).toBeInvalid();
expect(report).toHaveError("void-style", expect.any(String));
});
it("should be canceled by end tag", () => {
expect.assertions(2);
const markup = `
<div>
<!-- [html-validate-disable-next void-style] -->
</div>
<input type="hidden" />
`;
const source: Source[] = [inline(markup)];
const report = engine.lint(source);
expect(report).toBeInvalid();
expect(report).toHaveError("void-style", expect.any(String));
});
});
it('"disable-next" should disable rule on nodes', () => {
......@@ -329,20 +359,22 @@ describe("Engine", () => {
describe("dumpEvents()", () => {
it("should dump parser events", () => {
expect.assertions(11);
expect.assertions(13);
const source: Source[] = [inline('<div id="foo"><p class="bar">baz</p></div>')];
const lines = engine.dumpEvents(source);
expect(lines).toHaveLength(10);
expect(lines).toHaveLength(12);
expect(lines[0].event).toEqual("dom:load");
expect(lines[1].event).toEqual("tag:open");
expect(lines[1].event).toEqual("tag:start");
expect(lines[2].event).toEqual("attr");
expect(lines[3].event).toEqual("tag:open");
expect(lines[4].event).toEqual("attr");
expect(lines[5].event).toEqual("tag:close");
expect(lines[6].event).toEqual("element:ready");
expect(lines[7].event).toEqual("tag:close");
expect(lines[3].event).toEqual("tag:ready");
expect(lines[4].event).toEqual("tag:start");
expect(lines[5].event).toEqual("attr");
expect(lines[6].event).toEqual("tag:ready");
expect(lines[7].event).toEqual("tag:end");
expect(lines[8].event).toEqual("element:ready");
expect(lines[9].event).toEqual("dom:ready");
expect(lines[9].event).toEqual("tag:end");
expect(lines[10].event).toEqual("element:ready");
expect(lines[11].event).toEqual("dom:ready");
});
});
......
import { ConfigData, ResolvedConfig, RuleOptions, Severity } from "../config";
import { Location, Source } from "../context";
import { HtmlElement } from "../dom";
import { ConfigReadyEvent, DirectiveEvent, TagCloseEvent, TagOpenEvent } from "../event";
import { ConfigReadyEvent, DirectiveEvent, TagEndEvent, TagStartEvent } from "../event";
import { InvalidTokenError, Lexer, TokenType } from "../lexer";
import { Parser, ParserError } from "../parser";
import { Report, Reporter } from "../reporter";
......@@ -219,7 +219,7 @@ export class Engine<T extends Parser = Parser> {
}
/* enable rules on node */
parser.on("tag:open", (event: string, data: TagOpenEvent) => {
parser.on("tag:start", (event: string, data: TagStartEvent) => {
data.target.enableRules(rules.map((rule) => rule.name));
});
}
......@@ -230,7 +230,7 @@ export class Engine<T extends Parser = Parser> {
}
/* disable rules on node */
parser.on("tag:open", (event: string, data: TagOpenEvent) => {
parser.on("tag:start", (event: string, data: TagStartEvent) => {
data.target.disableRules(rules.map((rule) => rule.name));
});
}
......@@ -241,7 +241,7 @@ export class Engine<T extends Parser = Parser> {
rule.setEnabled(false);
}
const unregisterOpen = parser.on("tag:open", (event: string, data: TagOpenEvent) => {
const unregisterOpen = parser.on("tag:start", (event: string, data: TagStartEvent) => {
/* wait for a tag to open and find the current block by using its parent */
if (directiveBlock === null) {
directiveBlock = data.target.parent?.unique ?? null;
......@@ -252,7 +252,7 @@ export class Engine<T extends Parser = Parser> {
data.target.disableRules(rules.map((rule) => rule.name));
});
const unregisterClose = parser.on("tag:close", (event: string, data: TagCloseEvent) => {
const unregisterClose = parser.on("tag:end", (event: string, data: TagEndEvent) => {
/* if the directive is the last thing in a block no id would be set */
const lastNode = directiveBlock === null;
......@@ -278,12 +278,12 @@ export class Engine<T extends Parser = Parser> {
/* disable rules directly on the node so it will be recorded for later,
* more specifically when using the domtree to trigger errors */
const unregister = parser.on("tag:open", (event: string, data: TagOpenEvent) => {
const unregister = parser.on("tag:start", (event: string, data: TagStartEvent) => {
data.target.disableRules(rules.map((rule) => rule.name));
});
/* disable directive after next event occurs */
parser.once("tag:open, tag:close, attr", () => {
parser.once("tag:ready, tag:end, attr", () => {
unregister();
parser.defer(() => {
for (const rule of rules) {
......
......@@ -20,24 +20,27 @@ export interface ConfigReadyEvent extends Event {
}
/**
* Event emitted when opening tags are encountered.
* Event emitted when starting tags are encountered.
*/
export interface TagOpenEvent extends Event {
export interface TagStartEvent extends Event {
/** Event location. */
location: Location;
/** The node being opened. */
/** The node being started. */
target: HtmlElement;
}
/** Deprecated alias for TagStartEvent */
export type TagOpenEvent = TagStartEvent;
/**
* Event emitted when close tags `</..>` are encountered.
* Event emitted when end tags `</..>` are encountered.
*/
export interface TagCloseEvent extends Event {
export interface TagEndEvent extends Event {
/** Event location. */
location: Location;
/** Temporary node for the close tag. Can be null for elements left unclosed
/** Temporary node for the end tag. Can be null for elements left unclosed
* when document ends */
target: HtmlElement | null;
......@@ -45,6 +48,21 @@ export interface TagCloseEvent extends Event {
previous: HtmlElement;
}
/** Deprecated alias for TagEndEvent */
export type TagCloseEvent = TagEndEvent;
/**
* Event emitted when a tag is ready (i.e. all the attributes has been
* parsed). The children of the element will not yet be finished.
*/
export interface TagReadyEvent extends Event {
/** Event location. */
location: Location;
/** The node that is finished parsing. */
target: HtmlElement;
}
/**
* Event emitted when an element is fully constructed (including its children).
*/
......@@ -145,3 +163,36 @@ export interface DOMReadyEvent extends Event {
/** DOM Tree */
document: DOMTree;
}
export interface TriggerEventMap {
"config:ready": ConfigReadyEvent;
"tag:start": TagStartEvent;
"tag:end": TagEndEvent;
"tag:ready": TagReadyEvent;
"element:ready": ElementReadyEvent;
"dom:load": Event;
"dom:ready": DOMReadyEvent;
doctype: DoctypeEvent;
attr: AttributeEvent;
whitespace: WhitespaceEvent;
conditional: ConditionalEvent;
directive: DirectiveEvent;
}
export interface ListenEventMap {
"config:ready": ConfigReadyEvent;
"tag:open": TagOpenEvent;
"tag:start": TagStartEvent;
"tag:close": TagCloseEvent;
"tag:end": TagEndEvent;
"tag:ready": TagReadyEvent;
"element:ready": ElementReadyEvent;
"dom:load": Event;
"dom:ready": DOMReadyEvent;
doctype: DoctypeEvent;
attr: AttributeEvent;
whitespace: WhitespaceEvent;
conditional: ConditionalEvent;
directive: DirectiveEvent;
"*": Event;
}
This diff is collapsed.
......@@ -4,18 +4,12 @@ import { ProcessAttributeCallback, ProcessElementContext } from "../context/sour
import { DOMTree, HtmlElement, NodeClosed } from "../dom";
import {
AttributeEvent,
ConditionalEvent,
ConfigReadyEvent,
DirectiveEvent,
DoctypeEvent,
DOMReadyEvent,
ElementReadyEvent,
Event,
EventCallback,
EventHandler,
TagCloseEvent,
TagOpenEvent,
WhitespaceEvent,
ListenEventMap,
TagEndEvent,
TriggerEventMap,
} from "../event";
import { Lexer, Token, TokenStream, TokenType } from "../lexer";
import { MetaTable, MetaElement } from "../meta";
......@@ -177,7 +171,7 @@ export class Parser {
}
}
// eslint-disable-next-line complexity
/* eslint-disable-next-line complexity, sonarjs/cognitive-complexity */
protected consumeTag(source: Source, startToken: Token, tokenStream: TokenStream): void {
const tokens = Array.from(
this.consumeUntil(tokenStream, TokenType.TAG_CLOSE, startToken.location)
......@@ -186,9 +180,9 @@ export class Parser {
const closeOptional = this.closeOptional(startToken);
const parent = closeOptional ? this.dom.getActive().parent : this.dom.getActive();
const node = HtmlElement.fromTokens(startToken, endToken, parent, this.metaTable);
const open = !startToken.data[1];
const close = !open || node.closed !== NodeClosed.Open;
const foreign = node.meta && node.meta.foreign;
const isStartTag = !startToken.data[1];
const isClosing = !isStartTag || node.closed !== NodeClosed.Open;
const isForeign = node.meta && node.meta.foreign;
/* if the previous tag to be implicitly closed by the current tag we close
* it and pop it from the stack before continuing processing this tag */
......@@ -199,9 +193,9 @@ export class Parser {
this.dom.popActive();
}
if (open) {
if (isStartTag) {
this.dom.pushActive(node);
this.trigger("tag:open", {
this.trigger("tag:start", {
target: node,
location: startToken.location,
});
......@@ -218,12 +212,20 @@ export class Parser {
}
}
if (close) {
/* emit tag:ready unless this is a end tag */
if (isStartTag) {
this.trigger("tag:ready", {
target: node,
location: endToken.location,
});
}
if (isClosing) {
const active = this.dom.getActive();
/* if this is not an open tag it is a close tag and thus we force it to be
* one, in case it is detected as void */
if (!open) {
if (!isStartTag) {
node.closed = NodeClosed.EndTag;
}
......@@ -233,11 +235,11 @@ export class Parser {
* closed again (it is already closed automatically since it is
* void). Closing again will have side-effects as it will close the parent
* and cause a mess later. */
const voidClosed = !open && node.voidElement;
const voidClosed = !isStartTag && node.voidElement;
if (!voidClosed) {
this.dom.popActive();
}
} else if (foreign) {
} else if (isForeign) {
/* consume the body of the foreign element so it won't be part of the
* document (only the root foreign element is). */
this.discardForeignBody(source, node.tagName, tokenStream, startToken.location);
......@@ -254,12 +256,12 @@ export class Parser {
this.processElement(active, source);
/* trigger event for the closing of the element (the </> tag)*/
const event: TagCloseEvent = {
const event: TagEndEvent = {
target: node,
previous: active,
location,
};
this.trigger("tag:close", event);
this.trigger("tag:end", event);
/* trigger event for for an element being fully constructed. Special care
* for void elements explicit closed <input></input> */
......@@ -492,6 +494,11 @@ export class Parser {
* @param listener - Event callback.
* @returns A function to unregister the listener.
*/
public on<K extends keyof ListenEventMap>(
event: K,
listener: (event: string, data: ListenEventMap[K]) => void
): () => void;
public on(event: string, listener: EventCallback): () => void;
public on(event: string, listener: EventCallback): () => void {
return this.event.on(event, listener);
}
......@@ -504,6 +511,11 @@ export class Parser {
* @param listener - Event callback.
* @returns A function to unregister the listener.
*/
public once<K extends keyof ListenEventMap>(
event: K,
listener: (event: string, data: ListenEventMap[K]) => void
): () => void;
public once(event: string, listener: EventCallback): () => void;
public once(event: string, listener: EventCallback): () => void {
return this.event.once(event, listener);
}
......@@ -520,22 +532,11 @@ export class Parser {
/**
* Trigger event.
*
* @param {string} event - Event name
* @param {Event} data - Event data
* @param event - Event name
* @param data - Event data
*/
public trigger(event: "config:ready", data: ConfigReadyEvent): void;
public trigger(event: "tag:open", data: TagOpenEvent): void;
public trigger(event: "tag:close", data: TagCloseEvent): void;
public trigger(event: "element:ready", data: ElementReadyEvent): void;
public trigger(event: "dom:load", data: Event): void;
public trigger(event: "dom:ready", data: DOMReadyEvent): void;
public trigger(event: "doctype", data: DoctypeEvent): void;
public trigger(event: "attr", data: AttributeEvent): void;
public trigger(event: "whitespace", data: WhitespaceEvent): void;
public trigger(event: "conditional", data: ConditionalEvent): void;
public trigger(event: "directive", data: DirectiveEvent): void;
/* eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types */
public trigger(event: any, data: any): void {
public trigger<K extends keyof TriggerEventMap>(event: K, data: TriggerEventMap[K]): void;
public trigger(event: string, data: Event): void {
if (typeof data.location === "undefined") {
throw Error("Triggered event must contain location");
}
......
......@@ -2,7 +2,7 @@ import path from "path";
import { Config, Severity } from "./config";
import { Location } from "./context";
import { HtmlElement, NodeClosed } from "./dom";
import { Event } from "./event";
import { Event, EventCallback, TagEndEvent, TagStartEvent } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { Rule, ruleDocumentationUrl, IncludeExcludeOptions } from "./rule";
......@@ -28,6 +28,7 @@ const location: Location = {
describe("rule base class", () => {
let parser: Parser;
let parserOn: jest.SpyInstance<() => void, [event: string, listener: EventCallback]>;
let reporter: Reporter;
let meta: MetaTable;
let rule: MockRule;
......@@ -36,7 +37,7 @@ describe("rule base class", () => {
beforeEach(() => {
parser = new Parser(Config.empty().resolve());
parser.on = jest.fn();
parserOn = jest.spyOn(parser, "on");
reporter = new Reporter();
reporter.add = jest.fn();
meta = new MetaTable();
......@@ -125,7 +126,7 @@ describe("rule base class", () => {
expect.assertions(1);
const node = new HtmlElement("foo", null, NodeClosed.EndTag, null, location);
rule.on("*", () => null);
const callback = (parser.on as any).mock.calls[0][1];
const callback = parserOn.mock.calls[0][1];
callback("event", mockEvent);
rule.report(node, "foo");
expect(reporter.add).toHaveBeenCalledWith(
......@@ -193,7 +194,7 @@ describe("rule base class", () => {
rule.on("*", () => {
delivered = true;
});
callback = (parser.on as any).mock.calls[0][1];
callback = parserOn.mock.calls[0][1];
});
it('should not deliver events with severity "disabled"', () => {
......@@ -237,7 +238,7 @@ describe("rule base class", () => {
delivered = true;
}
);
callback = (parser.on as any).mock.calls[0][1];
callback = parserOn.mock.calls[0][1];
});
it("should deliver event when filter return true", () => {
......@@ -254,6 +255,31 @@ describe("rule base class", () => {
expect(delivered).toBeFalsy();
});
});
it("should support tag:open as alias for tag:start", () => {
expect.assertions(1);
const spy = jest.fn();
const eventData: TagStartEvent = {
location,
target: (null as unknown) as HtmlElement,
};
rule.on("tag:open", spy);
parser.trigger("tag:start", eventData);
expect(spy).toHaveBeenCalledWith(eventData);
});
it("should support tag:close as alias for tag:end", () => {
expect.assertions(1);
const spy = jest.fn();
const eventData: TagEndEvent = {
location,
target: (null as unknown) as HtmlElement,
previous: (null as unknown) as HtmlElement,
};
rule.on("tag:close", spy);
parser.trigger("tag:end", eventData);
expect(spy).toHaveBeenCalledWith(eventData);
});
});
it("documentation() should return null", () => {
......
......@@ -2,24 +2,18 @@ import path from "path";
import { Severity } from "./config";
import { Location } from "./context";
import { DOMNode } from "./dom";
import {
AttributeEvent,
ConditionalEvent,
DoctypeEvent,
DOMReadyEvent,
ElementReadyEvent,
Event,
TagCloseEvent,
TagOpenEvent,
WhitespaceEvent,
ConfigReadyEvent,
} from "./event";
import { Event, ListenEventMap } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { MetaTable, MetaLookupableProperty } from "./meta";
const homepage = require("../package.json").homepage;
const remapEvents: Record<string, string> = {
"tag:open": "tag:start",
"tag:close": "tag:end",
};
export interface RuleDocumentation {
description: string;
url?: string;
......@@ -203,36 +197,29 @@ export abstract class Rule<ContextType = void, OptionsType = void> {
* @param filter - Optional filter function. Callback is only called if filter functions return true.
* @param callback - Callback to handle event.
*/
/* prettier-ignore */ public on(event: "config:ready", callback: (event: ConfigReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "config:ready", filter: (event: ConfigReadyEvent) => boolean, callback: (event: ConfigReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "tag:open", callback: (event: TagOpenEvent) => void): void;
/* prettier-ignore */ public on(event: "tag:open", filter: (event: TagOpenEvent) => boolean, callback: (event: TagOpenEvent) => void): void;
/* prettier-ignore */ public on(event: "tag:close", callback: (event: TagCloseEvent) => void): void;
/* prettier-ignore */ public on(event: "tag:close", filter: (event: TagCloseEvent) => boolean, callback: (event: TagCloseEvent) => void): void;
/* prettier-ignore */ public on(event: "element:ready", callback: (event: ElementReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "element:ready", filter: (event: ElementReadyEvent) => boolean, callback: (event: ElementReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "dom:load", callback: (event: Event) => void): void;
/* prettier-ignore */ public on(event: "dom:load", filter: (event: Event) => boolean, callback: (event: Event) => void): void;
/* prettier-ignore */ public on(event: "dom:ready", callback: (event: DOMReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "dom:ready", filter: (event: DOMReadyEvent) => boolean, callback: (event: DOMReadyEvent) => void): void;
/* prettier-ignore */ public on(event: "doctype", callback: (event: DoctypeEvent) => void): void;
/* prettier-ignore */ public on(event: "doctype", filter: (event: DoctypeEvent) => boolean, callback: (event: DoctypeEvent) => void): void;
/* prettier-ignore */ public on(event: "attr", callback: (event: AttributeEvent) => void): void;
/* prettier-ignore */ public on(event: "attr", filter: (event: AttributeEvent) => boolean, callback: (event: AttributeEvent) => void): void;
/* prettier-ignore */ public on(event: "whitespace", callback: (event: WhitespaceEvent) => void): void;
/* prettier-ignore */ public on(event: "whitespace", filter: (event: WhitespaceEvent) => boolean, callback: (event: WhitespaceEvent) => void): void;
/* prettier-ignore */ public on(event: "conditional", callback: (event: ConditionalEvent) => void): void;
/* prettier-ignore */ public on(event: "conditional", filter: (event: ConditionalEvent) => boolean, callback: (event: ConditionalEvent) => void): void;
/* prettier-ignore */ public on(event: "*", callback: (event: Event) => void): void;
/* prettier-ignore */ public on(event: "*", filter: (event: Event) => boolean, callback: (event: Event) => void): void;
public on<TEvent extends Event>(
public on<K extends keyof ListenEventMap>(
event: K,
callback: (event: ListenEventMap[K]) => void
): void;
public on<K extends keyof ListenEventMap>(
event: K,
filter: (event: ListenEventMap[K]) => boolean,
callback: (event: ListenEventMap[K]) => void
): void;
public on(
event: string,
...args: [(event: TEvent) => void] | [(event: TEvent) => boolean, (event: TEvent) => void]
...args: [(event: Event) => void] | [(event: Event) => boolean, (event: Event) => void]
): void {
const callback = args.pop() as (event: TEvent) => void;
const filter = (args.pop() as (event: TEvent) => boolean) ?? (() => true);
/* handle deprecated aliases */
const remap = remapEvents[event];
if (remap) {
event = remap;
}
const callback = args.pop() as (event: Event) => void;
const filter = (args.pop() as (event: Event) => boolean) ?? (() => true);
this.parser.on(event, (_event: string, data: TEvent) => {
this.parser.on(event, (_event: string, data: Event) => {
if (this.isEnabled() && filter(data)) {
this.event = data;
callback(data);
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule heading-level should contain documentation 1`] = `
exports[`rule heading-level should contain documentation (with multiple h1) 1`] = `
Object {
"description": "Validates heading level increments and order. Headings must start at h1 and can only increase one level at a time.",
"description": "Headings must 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.",
"url": "https://html-validate.org/rules/heading-level.html",
}
`;
exports[`rule heading-level should contain documentation (without multiple h1) 1`] = `
Object {
"description": "Headings must 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",
}
`;
......@@ -17,7 +28,7 @@ Array [
"column": 2,
"context": undefined,
"line": 1,
"message": "Initial heading level must be h1",
"message": "Initial heading level must be <h1> but got <h2>",
"offset": 1,
"ruleId": "heading-level",
"selector": "h2",
......@@ -28,7 +39,7 @@ Array [
"column": 2,
"context": undefined,
"line": 6,
"message": "Heading level can only increase by one, expected h3",
"message": "Heading level can only increase by one, expected <h3> but got <h4>",
"offset": 73,
"ruleId": "heading-level",
"selector": "h4",
......
import { TagCloseEvent } from "../event";
import { TagEndEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
export default class CloseAttr extends Rule {
......@@ -10,7 +10,7 @@ export default class CloseAttr extends Rule {
}
public setup(): void {
this.on("tag:close", (event: TagCloseEvent) => {
this.on("tag:end", (event: TagEndEvent) => {
/* handle unclosed tags */
if (!event.target) {
return;
......
import { Location } from "../context";
import { NodeClosed } from "../dom";
import { TagCloseEvent } from "../event";
import { TagEndEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
export default class CloseOrder extends Rule {
......@@ -12,7 +12,7 @@ export default class CloseOrder extends Rule {
}
public setup(): void {
this.on("tag:close", (event: TagCloseEvent) => {
this.on("tag:end", (event: TagEndEvent) => {
const current = event.target; // The current element being closed
const active = event.previous; // The current active element (that is, the current element on the stack)
......
import { sliceLocation, Location } from "../context";
import { HtmlElement } from "../dom";
import { TagOpenEvent } from "../event";
import { TagStartEvent } from "../event";
import { DeprecatedElement } from "../meta/element";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
......@@ -33,7 +33,7 @@ export default class Deprecated extends Rule<Context> {
}
public setup(): void {
this.on("tag:open", (event: TagOpenEvent) => {
this.on("tag:start", (event: TagStartEvent) => {
const node = event.target;
/* cannot validate if meta isn't known */
......