...
 
Commits (14)
# html-validate changelog
# [2.10.0](https://gitlab.com/html-validate/html-validate/compare/v2.9.0...v2.10.0) (2020-01-22)
### Features
- **rules:** make options typesafe ([c85342a](https://gitlab.com/html-validate/html-validate/commit/c85342a5426ddba081fed8becaf3d4d499f0b66e))
- **rules:** new rule `prefer-native-element` ([06c44ce](https://gitlab.com/html-validate/html-validate/commit/06c44cec1c66b518c030a31517d8cfd454c0c2d2))
# [2.9.0](https://gitlab.com/html-validate/html-validate/compare/v2.8.2...v2.9.0) (2020-01-17)
### Features
......
......@@ -27,6 +27,13 @@ footer {
padding: 3rem 0;
}
table {
th,
td {
padding: $table-cell-padding;
}
}
.rules-table {
.set {
width: 5%;
......
......@@ -9,21 +9,17 @@ Rules are created by extending the `Rule` class and implementing the `setup`
method:
```typescript
// for vanilla javascript
const Rule = require("html-validate").Rule;
// for typescript
import { Rule } from "html-validate/src/rule";
import { Rule, RuleDocumentation } from "html-validate";
class MyRule extends Rule {
documentation(context?: any) {
documentation(): RuleDocumentation {
return {
description: "Lorem ipsum",
url: "https://example.net/best-practice/my-rule.html",
};
}
setup() {
setup(): void {
/* listen on dom ready event */
this.on("dom:ready", (event: DOMReadyEvent) => {
/* do something with the DOM tree */
......@@ -38,40 +34,142 @@ class MyRule extends Rule {
module.exports = MyRule;
```
For typescript generics can also be used when inheriting to specify the type of
the contextual data:
All (enabled) rules run the `setup()` callback before the source document is being parsed and is used to setup any event listeners relevant for this rule.
Many rules will use the `dom:ready` event to wait for the full DOM document to be ready but many other events are available, see {@link dev/events events} for a full list.
To report an error the rule uses the `report()` method with the DOM node and an error message.
Rules does not have to consider the severity or whenever the rule is enabled or not.
The message should be short and concise but still contain enough information to allow the user to understand what is wrong and why.
For a more verbose error (typically shown in IDEs and GUIs) the `documentation()` method is used.
This documentation might include contextual information (see below).
## Error location
By default the error is reported at the same location as the DOM node but if a better location can be provided it should be added as the third argument, typically by using the provided `sliceLocation` helper:
```typescript
interface ContextualData {
/* ... */
/* recommended: move start location by 5 characters */
const location = sliceLocation(node.location, 5);
/* not recommended: construct location manually */
const location = {
filename: node.location.filename,
line: 1,
column: 2,
offset: 1,
size: 5,
};
this.on(node, "asdf", location);
```
## Error context
Error messages may optionally include additional context.
Most CLI formatters will not include the context but JSON output and when using the API gives access to the contextual data.
This is very useful for IDE support as the short regular message might not always include enough information about why something is not allowed in a particular case.
The most important difference is that the context object is passed to the `documentation` method and can be used to give a better description of the error.
```typescript
interface RuleContext {
tagname: string;
allowed: string[];
}
class MyRule extends Rule<ContextualData> {
documentation(context?: ContextualData){ ... }
class MyRule extends Rule<RuleContext> {
/* documentation callback now accepts the optional context as first argument */
documentation(context?: RuleContext): RuleDocumentation {
/* setup the default documentation object (used when no context is available) */
const doc: RuleDocumentation = {
description: "This element cannot be used here.",
url: "https://example.net/my-rule",
};
/* if a context was passed include a better description */
if (context) {
const tagname = context.tagname;
const allowed = context.allowed.join(", ");
doc.description = `The ${tagname} element cannot be used here, only one of ${allowed} is allowed.`;
}
setup(){
const context: ContextualData = { .. };
this.report(node, "Message", null, context);
return doc;
}
setup(): void {
/* actual setup code left out for brevity */
/* create a context object for this error */
const context: RuleContext = {
tagname: node.tagName,
allowed: ["<b>", "<i>", "<u>"],
};
/* pass the context when reporting an error */
this.report(node, "This element cannot be used here", null, context);
}
}
```
<div class="alert alert-info">
<i class="fa fa-info-circle" aria-hidden="true"></i>
<strong>Note</strong>
<p>Even if your rule reports contextual data the API user might not pass it back to the <code>documentation()</code> call so you must always test if the context object was actually passed or not.</p>
</div>
## Options
If the rule has options create an interface and pass as the second template argument.
Options can either be accessed in the class constructor or on `this.options`.
If any option is required be use to include defaults as the use must not be required to enter any options in their configuration.
```typescript
interface RuleOptions {
text: string;
}
const defaults: RuleOptions = {
text: "lorem ipsum",
};
class MyRule extends Rule<void, RuleOptions> {
constructor(options: RuleOptions) {
/* assign default values if not provided by user */
super(Object.assign({}, defaults, options));
}
setup(): void {
/* actual setup code left out for brevity */
/* disallow the node from containing the text provided in the option */
if (node.textContent.inclues(this.options.text)) {
this.report(node, "Contains disallowed text");
}
}
}
```
## API
### `options: {[key: string]: any}`
### `options: RuleOptions`
Object with all the options passed from the configuration.
Options are accessed using `this.options`.
When using typescript: pass the datatype as the second template argument when extending `Rule`.
Default is `void` (i.e. no options)
### `on(event: string, callback: (event: Event)): void`
Listen for events. See [events](/dev/events.html) for a full list of available
events and data.
### `report(node: HtmlElement, message: string, location?: Location, context?: T): void`
### `report(node: DOMNode, message: string, location?: Location, context?: RuleContext): void`
Report a new error.
- `node` - The `HtmlElement` this error belongs to.
- `node` - The `DOMNode` this error belongs to.
- `message` - Error message
- _`location`_ - If set it is the precise location of the error. (Default: node
location)
......
......@@ -35,6 +35,7 @@
</div>
<div class="collapse navbar-collapse" id="navbar">
<ul class="nav navbar-nav">
<!-- [html-validate-disable-block prefer-native-element: bootstrap styles button way different and this is their recommended markup] -->
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">User guide <span class="caret"></span></a>
<ul class="dropdown-menu">
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/prefer-native-element.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/prefer-native-element.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 6,
"context": Object {
"replacement": "main",
"role": "main",
},
"line": 1,
"message": "Prefer to use the native <main> element",
"offset": 5,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
],
"source": "<div role=\\"main\\">
<p>Lorem ipsum</p>
</div>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<div role="main">
<p>Lorem ipsum</p>
</div>`;
markup["correct"] = `<main>
<p>Lorem ipsum</p>
</main>`;
describe("docs/rules/prefer-native-element.md", () => {
it("inline validation: incorrect", () => {
const htmlvalidate = new HtmlValidate({"rules":{"prefer-native-element":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
const htmlvalidate = new HtmlValidate({"rules":{"prefer-native-element":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: prefer-native-element
category: a17y
summary: Prefer to use native HTML element over roles
---
# Prefer to use native HTML element over roles (`prefer-native-element`)
While WAI-ARIA describes many [roles][wai-aria-roles] which can provide semantic information about what the element represents.
Support for roles is varying and since HTML5 has many native equivalent elements it is better to use the native when possible.
[wai-aria-roles]: https://www.w3.org/TR/wai-aria-1.1/#role_definitions
Table of equivalent elements:
<!-- [html-validate-disable-block element-required-attributes: marked does not generate tables with scope attribute] -->
| Role | Element |
| ------------- | ---------------------- |
| article | article |
| banner | header |
| button | button |
| cell | td |
| checkbox | input |
| complementary | aside |
| contentinfo | footer |
| figure | figure |
| form | form |
| heading | h1, h2, h3, h4, h5, h6 |
| input | input |
| link | a |
| list | ul, ol |
| listbox | select |
| listitem | li |
| main | main |
| navigation | nav |
| progressbar | progress |
| radio | input |
| region | section |
| table | table |
| textbox | textarea |
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="prefer-native-element">
<div role="main">
<p>Lorem ipsum</p>
</div>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="prefer-native-element">
<main>
<p>Lorem ipsum</p>
</main>
</validate>
## Options
This rule takes an optional object:
```javascript
{
"mapping": { .. },
"include": [],
"exclude": [],
}
```
### `mapping`
Object with roles to map to native elements.
This can be used to provide a custom map with roles and their replacement.
### `include`
If set only roles listed in this array generates errors.
### `exclude`
If set roles listed in this array is ignored.
This diff is collapsed.
{
"name": "html-validate",
"version": "2.9.0",
"version": "2.10.0",
"description": "html linter",
"keywords": [
"html",
......@@ -68,10 +68,7 @@
}
},
"lint-staged": {
"*.{ts,js,json,md,scss}": [
"prettier --write",
"git add"
]
"*.{ts,js,json,md,scss}": "prettier --write"
},
"prettier": "@html-validate/prettier-config",
"renovate": {
......@@ -103,7 +100,7 @@
"@html-validate/commitlint-config": "1.0.1",
"@html-validate/eslint-config": "1.0.7",
"@html-validate/prettier-config": "1.0.0",
"@html-validate/semantic-release-config": "1.0.5",
"@html-validate/semantic-release-config": "1.0.6",
"@types/babel__code-frame": "7.0.1",
"@types/estree": "0.0.42",
"@types/glob": "7.1.1",
......@@ -135,24 +132,24 @@
"grunt-contrib-copy": "1.0.0",
"grunt-postcss": "0.9.0",
"grunt-sass": "3.1.0",
"highlight.js": "9.17.1",
"highlight.js": "9.18.0",
"husky": "4.0.10",
"jest": "24.9.0",
"jest-diff": "24.9.0",
"jest-junit": "10.0.0",
"jquery": "3.4.1",
"lint-staged": "9.5.0",
"lint-staged": "10.0.1",
"load-grunt-tasks": "5.1.0",
"minimatch": "3.0.4",
"prettier": "1.19.1",
"sass": "1.24.4",
"semantic-release": "15.14.0",
"sass": "1.25.0",
"semantic-release": "16.0.2",
"serve-static": "1.14.1",
"strip-ansi": "6.0.0",
"ts-jest": "24.3.0",
"tslint": "5.20.1",
"tslint-config-prettier": "1.18.0",
"typescript": "3.7.4"
"typescript": "3.7.5"
},
"jest": {
"collectCoverage": true,
......
......@@ -30,6 +30,7 @@ module.exports = {
"no-raw-characters": "error",
"no-trailing-whitespace": "error",
"prefer-button": "error",
"prefer-native-element": "error",
"prefer-tbody": "error",
"svg-focusable": "error",
"unrecognized-char-ref": "error",
......
......@@ -6,8 +6,8 @@ import "../matchers";
import { MetaTable } from "../meta";
import { Parser, ParserError } from "../parser";
import { Reporter } from "../reporter";
import { Rule, RuleOptions } from "../rule";
import { Engine } from "./engine";
import { Rule } from "../rule";
import { Engine, RuleOptions } from "./engine";
function inline(source: string): Source {
return {
......
......@@ -10,7 +10,9 @@ import {
import { InvalidTokenError, Lexer, TokenType } from "../lexer";
import { Parser, ParserError } from "../parser";
import { Report, Reporter } from "../reporter";
import { Rule, RuleConstructor, RuleDocumentation, RuleOptions } from "../rule";
import { Rule, RuleConstructor, RuleDocumentation } from "../rule";
export type RuleOptions = Record<string, any>;
export interface EventDump {
event: string;
......@@ -426,7 +428,7 @@ export class Engine<T extends Parser = Parser> {
this.report(null, `Definition for rule '${name}' was not found`);
});
}
})({});
})();
}
private reportError(
......
export function parsePattern(pattern: string): RegExp {
export type PatternName = "kebabcase" | "camelcase" | "underscore" | string;
export function parsePattern(pattern: PatternName): RegExp {
switch (pattern) {
case "kebabcase":
return /^[a-z0-9-]+$/;
......@@ -15,7 +17,7 @@ export function parsePattern(pattern: string): RegExp {
}
}
export function describePattern(pattern: string): string {
export function describePattern(pattern: PatternName): string {
const regexp = parsePattern(pattern).toString();
switch (pattern) {
case "kebabcase":
......
......@@ -296,7 +296,7 @@ describe("Plugin", () => {
public setup(): void {
/* do nothing */
}
})({});
})();
mockPlugin.rules = {
"mock-rule": null /* instantiateRule is mocked, this can be anything */,
};
......
......@@ -100,12 +100,12 @@ export class Reporter {
};
}
public add(
rule: Rule,
public add<ContextType, OptionsType>(
rule: Rule<ContextType, OptionsType>,
message: string,
severity: number,
location: Location,
context?: any
context?: ContextType
): void {
if (!(location.filename in this.result)) {
this.result[location.filename] = [];
......
......@@ -8,7 +8,11 @@ import { Reporter } from "./reporter";
import { Rule, ruleDocumentationUrl } from "./rule";
import { MetaTable } from "./meta";
class MockRule extends Rule {
interface RuleContext {
foo: string;
}
class MockRule extends Rule<RuleContext> {
public setup(): void {
/* do nothing */
}
......@@ -18,7 +22,7 @@ describe("rule base class", () => {
let parser: Parser;
let reporter: Reporter;
let meta: MetaTable;
let rule: Rule;
let rule: MockRule;
let mockLocation: Location;
let mockEvent: Event;
......@@ -30,7 +34,7 @@ describe("rule base class", () => {
meta = new MetaTable();
meta.loadFromFile(path.join(__dirname, "../elements/html5.json"));
rule = new MockRule({});
rule = new MockRule();
rule.name = "mock-rule";
rule.init(parser, reporter, Severity.ERROR, meta);
mockLocation = { filename: "mock-file", offset: 1, line: 1, column: 2 };
......
......@@ -20,18 +20,14 @@ import { MetaTable, MetaLookupableProperty } from "./meta";
const homepage = require("../package.json").homepage;
export interface RuleOptions {
[key: string]: any;
}
export interface RuleDocumentation {
description: string;
url?: string;
}
export type RuleConstructor = new (options: RuleOptions) => Rule;
export type RuleConstructor = new (options?: any) => Rule;
export abstract class Rule<T = any> {
export abstract class Rule<ContextType = void, OptionsType = void> {
private reporter: Reporter;
private parser: Parser;
private meta: MetaTable;
......@@ -48,9 +44,9 @@ export abstract class Rule<T = any> {
/**
* Rule options.
*/
public readonly options: RuleOptions;
public readonly options: OptionsType;
public constructor(options: RuleOptions) {
public constructor(options: OptionsType) {
this.options = options;
this.enabled = true;
}
......@@ -100,7 +96,7 @@ export abstract class Rule<T = any> {
node: DOMNode,
message: string,
location?: Location,
context?: T
context?: ContextType
): void {
if (this.isEnabled() && (!node || node.ruleEnabled(this.name))) {
const where = this.findLocation({ node, location, event: this.event });
......@@ -202,7 +198,7 @@ export abstract class Rule<T = any> {
* additional documentation is available.
*/
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
public documentation(context?: T): RuleDocumentation {
public documentation(context?: ContextType): RuleDocumentation {
return null;
}
}
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule prefer-native-element default config smoketest 1`] = `
Array [
Object {
"errorCount": 22,
"filePath": "test-files/rules/prefer-native-element.html",
"messages": Array [
Object {
"column": 6,
"context": Object {
"replacement": "article",
"role": "article",
},
"line": 3,
"message": "Prefer to use the native <article> element",
"offset": 33,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "header",
"role": "banner",
},
"line": 4,
"message": "Prefer to use the native <header> element",
"offset": 60,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "button",
"role": "button",
},
"line": 5,
"message": "Prefer to use the native <button> element",
"offset": 86,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "td",
"role": "cell",
},
"line": 6,
"message": "Prefer to use the native <td> element",
"offset": 112,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "checkbox",
},
"line": 7,
"message": "Prefer to use the native <input> element",
"offset": 136,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 15,
},
Object {
"column": 6,
"context": Object {
"replacement": "aside",
"role": "complementary",
},
"line": 8,
"message": "Prefer to use the native <aside> element",
"offset": 164,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 20,
},
Object {
"column": 6,
"context": Object {
"replacement": "footer",
"role": "contentinfo",
},
"line": 9,
"message": "Prefer to use the native <footer> element",
"offset": 197,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 18,
},
Object {
"column": 6,
"context": Object {
"replacement": "figure",
"role": "figure",
},
"line": 10,
"message": "Prefer to use the native <figure> element",
"offset": 228,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "form",
"role": "form",
},
"line": 11,
"message": "Prefer to use the native <form> element",
"offset": 254,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "hN",
"role": "heading",
},
"line": 12,
"message": "Prefer to use the native <hN> element",
"offset": 278,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "input",
},
"line": 13,
"message": "Prefer to use the native <input> element",
"offset": 305,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "a",
"role": "link",
},
"line": 14,
"message": "Prefer to use the native <a> element",
"offset": 330,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "ul",
"role": "list",
},
"line": 15,
"message": "Prefer to use the native <ul> element",
"offset": 354,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "select",
"role": "listbox",
},
"line": 16,
"message": "Prefer to use the native <select> element",
"offset": 378,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "li",
"role": "listitem",
},
"line": 17,
"message": "Prefer to use the native <li> element",
"offset": 405,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 15,
},
Object {
"column": 6,
"context": Object {
"replacement": "main",
"role": "main",
},
"line": 18,
"message": "Prefer to use the native <main> element",
"offset": 433,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "nav",
"role": "navigation",
},
"line": 19,
"message": "Prefer to use the native <nav> element",
"offset": 457,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 17,
},
Object {
"column": 6,
"context": Object {
"replacement": "progress",
"role": "progressbar",
},
"line": 20,
"message": "Prefer to use the native <progress> element",
"offset": 487,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 18,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "radio",
},
"line": 21,
"message": "Prefer to use the native <input> element",
"offset": 518,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "section",
"role": "region",
},
"line": 22,
"message": "Prefer to use the native <section> element",
"offset": 543,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "table",
"role": "table",
},
"line": 23,
"message": "Prefer to use the native <table> element",
"offset": 569,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "textarea",
"role": "textbox",
},
"line": 24,
"message": "Prefer to use the native <textarea> element",
"offset": 594,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
],
"source": "<div role=\\"unknown\\"></div>
<div role=\\"article\\"></div>
<div role=\\"banner\\"></div>
<div role=\\"button\\"></div>
<div role=\\"cell\\"></div>
<div role=\\"checkbox\\"></div>
<div role=\\"complementary\\"></div>
<div role=\\"contentinfo\\"></div>
<div role=\\"figure\\"></div>
<div role=\\"form\\"></div>
<div role=\\"heading\\"></div>
<div role=\\"input\\"></div>
<div role=\\"link\\"></div>
<div role=\\"list\\"></div>
<div role=\\"listbox\\"></div>
<div role=\\"listitem\\"></div>
<div role=\\"main\\"></div>
<div role=\\"navigation\\"></div>
<div role=\\"progressbar\\"></div>
<div role=\\"radio\\"></div>
<div role=\\"region\\"></div>
<div role=\\"table\\"></div>
<div role=\\"textbox\\"></div>
",
"warningCount": 0,
},
]
`;
exports[`rule prefer-native-element should contain contextual documentation 1`] = `
Object {
"description": "Instead of using the WAI-ARIA role \\"the-role\\" prefer to use the native <the-replacement> element.",
"url": "https://html-validate.org/rules/prefer-native-element.html",
}
`;
exports[`rule prefer-native-element should contain documentation 1`] = `
Object {
"description": "Instead of using WAI-ARIA roles prefer to use the native HTML elements.",
"url": "https://html-validate.org/rules/prefer-native-element.html",
}
`;
import { HtmlElement } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { CaseStyle } from "./helper/case-style";
import { CaseStyle, CaseStyleName } from "./helper/case-style";
const defaults = {
interface RuleOptions {
style: CaseStyleName | CaseStyleName[];
ignoreForeign: boolean;
}
const defaults: RuleOptions = {
style: "lowercase",
ignoreForeign: true,
};
class AttrCase extends Rule {
class AttrCase extends Rule<void, RuleOptions> {
private style: CaseStyle;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.style = new CaseStyle(this.options.style, "attr-case");
}
......
......@@ -8,12 +8,17 @@ enum QuoteStyle {
AUTO_QUOTE = "auto",
}
const defaults = {
interface Options {
style?: '"' | "'" | "auto";
unquoted?: boolean;
}
const defaults: Options = {
style: "auto",
unquoted: false,
};
class AttrQuotes extends Rule {
class AttrQuotes extends Rule<void, Options> {
private style: QuoteStyle;
public documentation(): RuleDocumentation {
......@@ -30,7 +35,7 @@ class AttrQuotes extends Rule {
}
}
public constructor(options: object) {
public constructor(options: Options) {
super(Object.assign({}, defaults, options));
this.style = parseStyle(this.options.style);
}
......
......@@ -3,16 +3,20 @@ import { DOMReadyEvent } from "../event";
import { PermittedAttribute } from "../meta/element";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface Options {
style?: string;
}
const defaults: Options = {
style: "omit",
};
type checkFunction = (attr: Attribute) => boolean;
class AttributeBooleanStyle extends Rule {
class AttributeBooleanStyle extends Rule<void, Options> {
private hasInvalidStyle: checkFunction;
public constructor(options: object) {
public constructor(options: Options) {
super(Object.assign({}, defaults, options));
this.hasInvalidStyle = parseStyle(this.options.style);
}
......
import { DOMTokenList } from "../dom";
import { AttributeEvent } from "../event";
import { describePattern, parsePattern } from "../pattern";
import { describePattern, parsePattern, PatternName } from "../pattern";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface RuleOptions {
pattern: PatternName;
}
const defaults: RuleOptions = {
pattern: "kebabcase",
};
class ClassPattern extends Rule {
class ClassPattern extends Rule<void, RuleOptions> {
private pattern: RegExp;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.pattern = parsePattern(this.options.pattern);
}
......
......@@ -2,16 +2,20 @@ import { Location, sliceLocation } from "../context";
import { HtmlElement } from "../dom";
import { TagCloseEvent, TagOpenEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { CaseStyle } from "./helper/case-style";
import { CaseStyle, CaseStyleName } from "./helper/case-style";
const defaults = {
interface RuleOptions {
style: CaseStyleName;
}
const defaults: RuleOptions = {
style: "lowercase",
};
class ElementCase extends Rule {
class ElementCase extends Rule<void, RuleOptions> {
private style: CaseStyle;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.style = new CaseStyle(this.options.style, "element-case");
}
......
......@@ -2,16 +2,22 @@ import { sliceLocation } from "../context";
import { TagOpenEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface RuleOptions {
pattern: string;
whitelist: string[];
blacklist: string[];
}
const defaults: RuleOptions = {
pattern: "^[a-z][a-z0-9\\-._]*-[a-z0-9\\-._]*$",
whitelist: [] as string[],
blacklist: [] as string[],
whitelist: [],
blacklist: [],
};
class ElementName extends Rule {
class ElementName extends Rule<void, RuleOptions> {
private pattern: RegExp;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
// eslint-disable-next-line security/detect-non-literal-regexp
......
......@@ -39,7 +39,7 @@ it("should handle multiple patterns", () => {
it("should throw exception for unknown styles", () => {
expect.assertions(1);
expect(() => {
return new CaseStyle("unknown-style", "test-case");
return new CaseStyle("unknown-style" as any, "test-case");
}).toThrow('Invalid style "unknown-style" for test-case rule');
});
......
import { ConfigError } from "../../config/error";
export type CaseStyleName =
| "lowercase"
| "uppercase"
| "pascalcase"
| "camelcase";
interface Style {
pattern: RegExp;
name: string;
......@@ -14,7 +20,7 @@ export class CaseStyle {
/**
* @param style - Name of a valid case style.
*/
public constructor(style: string | string[], ruleId: string) {
public constructor(style: CaseStyleName | CaseStyleName[], ruleId: string) {
if (!Array.isArray(style)) {
style = [style];
}
......@@ -46,7 +52,7 @@ export class CaseStyle {
}
}
private parseStyle(style: string[], ruleId: string): Style[] {
private parseStyle(style: CaseStyleName[], ruleId: string): Style[] {
return style.map(
(cur: string): Style => {
switch (cur.toLowerCase()) {
......
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { describePattern, parsePattern } from "../pattern";
import { describePattern, parsePattern, PatternName } from "../pattern";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface RuleOptions {
pattern: PatternName;
}
const defaults: RuleOptions = {
pattern: "kebabcase",
};
class IdPattern extends Rule {
class IdPattern extends Rule<void, RuleOptions> {
private pattern: RegExp;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.pattern = parsePattern(this.options.pattern);
}
......
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface RuleOptions {
maxlength: number;
}
const defaults: RuleOptions = {
maxlength: 70,
};
class LongTitle extends Rule {
class LongTitle extends Rule<void, RuleOptions> {
private maxlength: number;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.maxlength = parseInt(this.options.maxlength, 10);
this.maxlength = this.options.maxlength;
}
public documentation(): RuleDocumentation {
......
......@@ -3,7 +3,11 @@ import { NodeType } from "../dom";
import { AttributeEvent, ElementReadyEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
const defaults = {
interface RuleOptions {
relaxed: boolean;
}
const defaults: RuleOptions = {
relaxed: false,
};
......@@ -21,10 +25,10 @@ const replacementTable: Map<string, string> = new Map([
["`", "&grave;"],
]);
class NoRawCharacters extends Rule {
class NoRawCharacters extends Rule<void, RuleOptions> {
private relaxed: boolean;
public constructor(options: object) {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
this.relaxed = this.options.relaxed;
}
......
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
describe("rule prefer-native-element", () => {
describe("default config", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
});
it("should not report error when using role without native equivalent element", () => {
const report = htmlvalidate.validateString('<div role="unknown"></div>');
expect(report).toBeValid();
});
it("should not report error when role is boolean", () => {
const report = htmlvalidate.validateString("<div role></div>");
expect(report).toBeValid();
});
it("should not report error for dynamic attributes", () => {
const report = htmlvalidate.validateString(
'<input dynamic-role="main">',
null,
{
processAttribute,
}
);
expect(report).toBeValid();
});
it("should report error when using role with native equivalent element", () => {
const report = htmlvalidate.validateString('<div role="main"></div>');
expect(report).toBeInvalid();
expect(report).toHaveError(
"prefer-native-element",
"Prefer to use the native <main> element"
);
});
it("should handle unquoted role", () => {
const report = htmlvalidate.validateString("<div role=main></div>");
expect(report).toBeInvalid();
expect(report).toHaveError(
"prefer-native-element",
"Prefer to use the native <main> element"
);
});
it("smoketest", () => {
const report = htmlvalidate.validateFile(
"test-files/rules/prefer-native-element.html"
);
expect(report.results).toMatchSnapshot();
});
});
it("should not report error when role is excluded", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": ["error", { exclude: ["main"] }] },
});
const valid = htmlvalidate.validateString('<div role="main"></div>');
const invalid = htmlvalidate.validateString('<div role="article"></div>');
expect(valid).toBeValid();
expect(invalid).toBeInvalid();
});
it("should report error only for included roles", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": ["error", { include: ["main"] }] },
});
const valid = htmlvalidate.validateString('<div role="article"></div>');
const invalid = htmlvalidate.validateString('<div role="main"></div>');
expect(valid).toBeValid();
expect(invalid).toBeInvalid();
});
it("should contain documentation", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
expect(
htmlvalidate.getRuleDocumentation("prefer-native-element")
).toMatchSnapshot();
});
it("should contain contextual documentation", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
const context = {
role: "the-role",
replacement: "the-replacement",
};
expect(
htmlvalidate.getRuleDocumentation("prefer-native-element", null, context)
).toMatchSnapshot();
});
});
import { Location } from "../context";
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
interface RuleContext {
role: string;
replacement: string;
}
interface RuleOptions {
mapping: Record<string, string>;
include: string[] | null;
exclude: string[] | null;
}
const defaults: RuleOptions = {
mapping: {
article: "article",
banner: "header",
button: "button",
cell: "td",
checkbox: "input",
complementary: "aside",
contentinfo: "footer",
figure: "figure",
form: "form",
heading: "hN",
input: "input",
link: "a",
list: "ul",
listbox: "select",
listitem: "li",
main: "main",
navigation: "nav",
progressbar: "progress",
radio: "input",
region: "section",
table: "table",
textbox: "textarea",
},
include: null,
exclude: null,
};
class PreferNativeElement extends Rule<RuleContext, RuleOptions> {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
}
public documentation(context: RuleContext): RuleDocumentation {
const doc: RuleDocumentation = {
description: `Instead of using WAI-ARIA roles prefer to use the native HTML elements.`,
url: ruleDocumentationUrl(__filename),
};
if (context) {
doc.description = `Instead of using the WAI-ARIA role "${context.role}" prefer to use the native <${context.replacement}> element.`;
}
return doc;
}
public setup(): void {
const { mapping } = this.options;
this.on("attr", (event: AttributeEvent) => {
/* ignore non-role attributes */
if (event.key.toLowerCase() !== "role") {
return;
}
/* ignore missing and dynamic values */
if (!event.value || event.value instanceof DynamicValue) {
return;
}
/* ignore roles configured to be ignored */
const role = event.value.toLowerCase();
if (this.isIgnored(role)) {
return;
}
/* report error */
const replacement = mapping[role];
const context: RuleContext = { role, replacement };
const location = this.getLocation(event);
this.report(
event.target,
`Prefer to use the native <${replacement}> element`,
location,
context