Commit b813aeb7 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat: new rule `attr-pattern`

fixes #118
parent 96fe2054
Pipeline #319981941 passed with stages
in 9 minutes and 30 seconds
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/attr-pattern.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/attr-pattern.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 4,
"context": undefined,
"line": 1,
"message": "Attribute \\"foo_bar\\" should match /[a-z0-9-:]+/",
"offset": 3,
"ruleId": "attr-pattern",
"ruleUrl": "https://html-validate.org/rules/attr-pattern.html",
"selector": "p",
"severity": 2,
"size": 7,
},
],
"source": "<p foo_bar=\\"baz\\"></p>",
"warningCount": 0,
},
]
`;
exports[`docs/rules/attr-pattern.md inline validation: multiple 1`] = `Array []`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<p foo_bar="baz"></p>`;
markup["correct"] = `<p foo-bar="baz"></p>`;
markup["multiple"] = `<p foo-bar-123></p>
<p myprefix-foo_123!></p>`;
describe("docs/rules/attr-pattern.md", () => {
it("inline validation: incorrect", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"attr-pattern":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"attr-pattern":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: multiple", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"attr-pattern":["error",{"pattern":["[a-z0-9-]+","myprefix-.+"]}]}});
const report = htmlvalidate.validateString(markup["multiple"]);
expect(report.results).toMatchSnapshot();
});
});
......@@ -8,6 +8,7 @@ summary: Require a specific case for attribute names
# Attribute name case (`attr-case`)
Requires a specific case for attribute names.
This rule matches case for letters only, for restricting allowed characters use {@link rules/attr-pattern}.
## Rule details
......
---
docType: rule
name: attr-pattern
category: style
summary: Require attributes to match configured patterns
---
# Attribute name pattern (`attr-pattern`)
Require attributes to match configured patterns.
This rule is case-insensitive, for matching case use {@link rules/attr-case}.
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="attr-pattern">
<p foo_bar="baz"></p>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="attr-pattern">
<p foo-bar="baz"></p>
</validate>
## Options
This rule takes an optional object:
```javascript
{
"pattern": "[a-z0-9-:]+",
"ignoreForeign": true
}
```
### `pattern`
- type: `string | string[]`
- default: `[a-z0-9-:]+`
Pattern to match.
Multiple patterns can be set as an array of strings.
With multiple patterns the attribute must match at least one pattern to be considered valid.
For instance, when configured with `{"pattern": ["[a-z0-9-]+", "myprefix-.+"]}` attributes can be either letters and digits or anything with the `myprefix-` prefix:
<validate name="multiple" rules="attr-pattern" attr-pattern='{"pattern": ["[a-z0-9-]+", "myprefix-.+"]}'>
<p foo-bar-123></p>
<p myprefix-foo_123!></p>
</validate>
### `ignoreForeign`
By default attributes on foreign elements (such as `<svg>` and `<math>`) are ignored as they follow their own specifications.
Disable this option if you want to validate attributes on foreign elements as well.
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule attr-pattern should contain contextual documentation (multiple patterns) 1`] = `
Object {
"description": "Attribute \\"foobar\\" should match one of the configured regular expressions:
- \`/[a-z]+/\`
- \`/[0-9]+/\`",
"url": "https://html-validate.org/rules/attr-pattern.html",
}
`;
exports[`rule attr-pattern should contain contextual documentation (single pattern) 1`] = `
Object {
"description": "Attribute \\"foobar\\" should match the regular expression \`/[a-z]+/\`",
"url": "https://html-validate.org/rules/attr-pattern.html",
}
`;
exports[`rule attr-pattern should contain documentation 1`] = `
Object {
"description": "Attribute should match configured pattern",
"url": "https://html-validate.org/rules/attr-pattern.html",
}
`;
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
import { DEFAULT_PATTERN } from "./attr-pattern";
describe("rule attr-pattern", () => {
let htmlvalidate: HtmlValidate;
describe("default configuration", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": "error" },
});
});
it("should not report error when attribute has letters and characters only", () => {
expect.assertions(1);
const markup = "<div foo></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when attribute has digits", () => {
expect.assertions(1);
const markup = "<div foo></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when attribute has dashes", () => {
expect.assertions(1);
const markup = "<div foo></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when attribute has xml namespace", () => {
expect.assertions(1);
const markup = "<div xfoo:bar></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it.each`
attr | description
${"foo_bar"} | ${"underscore"}
`("should report error when attribute has $description", ({ attr }) => {
expect.assertions(2);
const markup = `<div ${attr}></div>`;
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"attr-pattern",
`Attribute "${attr}" should match /${DEFAULT_PATTERN}/`
);
});
});
describe("configured with single pattern", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { pattern: "[a-z]+" }] },
});
});
it("should not report error when attribute has allowed characters only", () => {
expect.assertions(1);
const markup = "<div foo></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should report error when attribute has other characters", () => {
expect.assertions(2);
const markup = "<div foo-2000></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError("attr-pattern", 'Attribute "foo-2000" should match /[a-z]+/');
});
});
describe("configured with multiple patterns", () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { pattern: ["[a-z]+", "[0-9]+"] }] },
});
});
it("should not report error when attributes matches one of the allowed patterns", () => {
expect.assertions(1);
const markup = "<div foo 123></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should report error when attribute doesn't match any pattern", () => {
expect.assertions(2);
const markup = "<div foo-123></div>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"attr-pattern",
'Attribute "foo-123" should match one of [/[a-z]+/, /[0-9]+/]'
);
});
});
describe('configured with "ignoreForeign" true', () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { ignoreForeign: true }] },
});
});
it("should not report error on foreign elements", () => {
expect.assertions(1);
const markup = "<svg foo_bar/>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
describe('configured with "ignoreForeign" false', () => {
beforeAll(() => {
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { ignoreForeign: false }] },
});
});
it("should report error on foreign elements", () => {
expect.assertions(2);
const markup = "<svg foo_bar/>";
const report = htmlvalidate.validateString(markup);
expect(report).toBeInvalid();
expect(report).toHaveError(
"attr-pattern",
`Attribute "foo_bar" should match /${DEFAULT_PATTERN}/`
);
});
});
it("should not report duplicate errors for dynamic attributes", () => {
expect.assertions(2);
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": "error" },
});
const markup = '<input dynamic-foo_bar="foo">';
const report = htmlvalidate.validateString(markup, {
processAttribute,
});
expect(report).toBeInvalid();
expect(report).toHaveErrors([
{
ruleId: "attr-pattern",
message: `Attribute "dynamic-foo_bar" should match /${DEFAULT_PATTERN}/`,
},
]);
});
it("should throw error if configured with invalid regexp", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { pattern: "[" }] },
});
expect(() => htmlvalidate.validateString("")).toThrowErrorMatchingInlineSnapshot(
`"Invalid regular expression: /^[$/: Unterminated character class"`
);
});
it("should throw error if configured with no patterns", () => {
expect.assertions(1);
expect(() => {
return new HtmlValidate({
root: true,
rules: { "attr-pattern": ["error", { pattern: [] }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/attr-pattern/1/pattern: minItems should NOT have fewer than 1 items"`
);
});
it("should contain documentation", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": "error" },
});
expect(htmlvalidate.getRuleDocumentation("attr-pattern")).toMatchSnapshot();
});
it("should contain contextual documentation (single pattern)", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": "error" },
});
const context = {
attr: "foobar",
pattern: "[a-z]+",
};
expect(htmlvalidate.getRuleDocumentation("attr-pattern", null, context)).toMatchSnapshot();
});
it("should contain contextual documentation (multiple patterns)", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
root: true,
rules: { "attr-pattern": "error" },
});
const context = {
attr: "foobar",
pattern: ["[a-z]+", "[0-9]+"],
};
expect(htmlvalidate.getRuleDocumentation("attr-pattern", null, context)).toMatchSnapshot();
});
});
import { HtmlElement } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleOptions {
pattern: string | string[];
ignoreForeign: boolean;
}
interface RuleContext {
attr: string;
pattern: string | string[];
}
export const DEFAULT_PATTERN = "[a-z0-9-:]+";
const defaults: RuleOptions = {
pattern: DEFAULT_PATTERN,
ignoreForeign: true,
};
function generateRegexp(pattern: string | string[]): RegExp {
if (Array.isArray(pattern)) {
/* eslint-disable-next-line security/detect-non-literal-regexp */
return new RegExp(`^(${pattern.join("|")})$`, "i");
} else {
/* eslint-disable-next-line security/detect-non-literal-regexp */
return new RegExp(`^${pattern}$`, "i");
}
}
function generateMessage(name: string, pattern: string | string[]): string {
if (Array.isArray(pattern)) {
return `Attribute "${name}" should match one of [${pattern.map((it) => `/${it}/`).join(", ")}]`;
} else {
return `Attribute "${name}" should match /${pattern}/`;
}
}
function generateDescription(name: string, pattern: string | string[]): string {
if (Array.isArray(pattern)) {
return [
`Attribute "${name}" should match one of the configured regular expressions:`,
"",
...pattern.map((it) => `- \`/${it}/\``),
].join("\n");
} else {
return `Attribute "${name}" should match the regular expression \`/${pattern}/\``;
}
}
export default class AttrPattern extends Rule<RuleContext, RuleOptions> {
private pattern: RegExp;
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.pattern = generateRegexp(this.options.pattern);
}
public static schema(): SchemaObject {
return {
pattern: {
oneOf: [{ type: "array", items: { type: "string" }, minItems: 1 }, { type: "string" }],
},
ignoreForeign: {
type: "boolean",
},
};
}
public documentation(context?: RuleContext): RuleDocumentation {
let description: string;
if (context) {
description = generateDescription(context.attr, context.pattern);
} else {
description = `Attribute should match configured pattern`;
}
return {
description,
url: ruleDocumentationUrl(__filename),
};
}
public setup(): void {
this.on("attr", (event: AttributeEvent) => {
if (this.isIgnored(event.target)) {
return;
}
/* ignore case for dynamic attributes, the original attributes will be
* checked instead (this prevents duplicated errors for the same source
* attribute) */
if (event.originalAttribute) {
return;
}
if (this.pattern.test(event.key)) {
return;
}
const message = generateMessage(event.key, this.options.pattern);
this.report(event.target, message, event.keyLocation);
});
}
protected isIgnored(node: HtmlElement): boolean {
if (this.options.ignoreForeign) {
return Boolean(node.meta && node.meta.foreign);
} else {
return false;
}
}
}
......@@ -2,6 +2,7 @@ import { RuleConstructor } from "../rule";
import AllowedLinks from "./allowed-links";
import AriaLabelMisuse from "./aria-label-misuse";
import AttrCase from "./attr-case";
import AttrPattern from "./attr-pattern";
import AttrQuotes from "./attr-quotes";
import AttrSpacing from "./attr-spacing";
import AttributeAllowedValues from "./attribute-allowed-values";
......@@ -66,6 +67,7 @@ const bundledRules: Record<string, RuleConstructor<any, any>> = {
"allowed-links": AllowedLinks,
"aria-label-misuse": AriaLabelMisuse,
"attr-case": AttrCase,
"attr-pattern": AttrPattern,
"attr-quotes": AttrQuotes,
"attr-spacing": AttrSpacing,
"attribute-allowed-values": AttributeAllowedValues,
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment