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

feat: add rule option schemas

parent 5b8ca066
Pipeline #281103432 passed with stages
in 10 minutes and 42 seconds
......@@ -24,7 +24,7 @@ describe("docs/rules/require-sri.md", () => {
});
it("inline validation: crossorigin", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"require-sri":["error",{"target":"crossdomain"}]}});
const htmlvalidate = new HtmlValidate({"rules":{"require-sri":["error",{"target":"crossorigin"}]}});
const report = htmlvalidate.validateString(markup["crossorigin"]);
expect(report.results).toMatchSnapshot();
});
......
......@@ -48,7 +48,7 @@ that the logic for determining crossdomain is a bit naïve, resources with a ful
url (`protocol://`) or implicit protocol (`//`) counts as crossorigin even if it
technically would point to the same origin.
<validate name="crossorigin" rules="require-sri" require-sri='{"target": "crossdomain"}'>
<validate name="crossorigin" rules="require-sri" require-sri='{"target": "crossorigin"}'>
<!--- local resource -->
<link href="local.css">
......
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
export const enum Style {
EXTERNAL = "external",
......@@ -44,6 +44,23 @@ export default class AllowedLinks extends Rule<Style, RuleOptions> {
super({ ...defaults, ...options });
}
public static schema(): SchemaObject {
return {
allowAbsolute: {
type: "boolean",
},
allowBase: {
type: "boolean",
},
allowExternal: {
type: "boolean",
},
allowRelative: {
type: "boolean",
},
};
}
public documentation(context: Style): RuleDocumentation {
const message =
description[context] || "This link type is not allowed by current configuration";
......
......@@ -228,11 +228,12 @@ describe("rule attr-case", () => {
it("should throw error if configured with invalid value", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "attr-case": ["error", { style: "foobar" }] },
});
expect(() => htmlvalidate.validateString("<foo></foo>")).toThrow(
`Invalid style "foobar" for attr-case rule`
expect(() => {
return new HtmlValidate({
rules: { "attr-case": ["error", { style: "foobar" }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/attr-case/1/style should be equal to one of the allowed values: lowercase, uppercase, pascalcase, camelcase"`
);
});
......
import { HtmlElement } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
import { CaseStyle, CaseStyleName } from "./helper/case-style";
interface RuleOptions {
......@@ -21,6 +21,30 @@ export default class AttrCase extends Rule<void, RuleOptions> {
this.style = new CaseStyle(this.options.style, "attr-case");
}
public static schema(): SchemaObject {
const styleEnum = ["lowercase", "uppercase", "pascalcase", "camelcase"];
return {
ignoreForeign: {
type: "boolean",
},
style: {
anyOf: [
{
enum: styleEnum,
type: "string",
},
{
items: {
enum: styleEnum,
type: "string",
},
type: "array",
},
],
},
};
}
public documentation(): RuleDocumentation {
return {
description: `Attribute name must be ${this.options.style}.`,
......
......@@ -124,14 +124,15 @@ describe("rule attr-quotes", () => {
});
});
it("should default to double quotes for invalid style", () => {
expect.assertions(2);
htmlvalidate = new HtmlValidate({
rules: { "attr-quotes": ["error", { style: "foobar" }] },
});
const report = htmlvalidate.validateString("<div foo='bar'></div>");
expect(report).toBeInvalid();
expect(report).toHaveError("attr-quotes", `Attribute "foo" used ' instead of expected "`);
it("should throw error if configured with invalid value", () => {
expect.assertions(1);
expect(() => {
return new HtmlValidate({
rules: { "attr-quotes": ["error", { style: "foobar" }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/attr-quotes/1/style should be equal to one of the allowed values: auto, double, single"`
);
});
it("should contain documentation (auto)", () => {
......
import { ConfigError } from "../config";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
type QuoteMark = '"' | "'";
enum QuoteStyle {
......@@ -8,19 +9,31 @@ enum QuoteStyle {
AUTO_QUOTE = "auto",
}
interface Options {
style: '"' | "'" | "auto";
interface RuleOptions {
style: "auto" | "single" | "double";
unquoted: boolean;
}
const defaults: Options = {
const defaults: RuleOptions = {
style: "auto",
unquoted: false,
};
export default class AttrQuotes extends Rule<void, Options> {
export default class AttrQuotes extends Rule<void, RuleOptions> {
private style: QuoteStyle;
public static schema(): SchemaObject {
return {
style: {
enum: ["auto", "double", "single"],
type: "string",
},
unquoted: {
type: "boolean",
},
};
}
public documentation(): RuleDocumentation {
if (this.options.style === "auto") {
return {
......@@ -35,7 +48,7 @@ export default class AttrQuotes extends Rule<void, Options> {
}
}
public constructor(options: Partial<Options>) {
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.style = parseStyle(this.options.style);
}
......@@ -82,7 +95,8 @@ function parseStyle(style: string): QuoteStyle {
return QuoteStyle.DOUBLE_QUOTE;
case "single":
return QuoteStyle.SINGLE_QUOTE;
/* istanbul ignore next: covered by schema validation */
default:
return QuoteStyle.DOUBLE_QUOTE;
throw new ConfigError(`Invalid style "${style}" for "attr-quotes" rule`);
}
}
......@@ -205,11 +205,12 @@ describe("rule attribute-boolean-style", () => {
it("should throw error if configured with invalid value", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "attribute-boolean-style": ["error", { style: "foobar" }] },
});
expect(() => htmlvalidate.validateString("<foo></foo>")).toThrow(
`Invalid style "foobar" for "attribute-boolean-style" rule`
expect(() => {
return new HtmlValidate({
rules: { "attribute-boolean-style": ["error", { style: "foobar" }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/attribute-boolean-style/1/style should be equal to one of the allowed values: empty, name, omit"`
);
});
......
import { Attribute, HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { PermittedAttribute } from "../meta/element";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface Options {
style: string;
interface RuleOptions {
style: "omit" | "empty" | "name";
}
const defaults: Options = {
const defaults: RuleOptions = {
style: "omit",
};
type checkFunction = (attr: Attribute) => boolean;
export default class AttributeBooleanStyle extends Rule<void, Options> {
export default class AttributeBooleanStyle extends Rule<void, RuleOptions> {
private hasInvalidStyle: checkFunction;
public constructor(options: Partial<Options>) {
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.hasInvalidStyle = parseStyle(this.options.style);
}
public static schema(): SchemaObject {
return {
style: {
enum: ["empty", "name", "omit"],
type: "string",
},
};
}
public documentation(): RuleDocumentation {
return {
description: "Require a specific style when writing boolean attributes.",
......@@ -71,6 +80,7 @@ function parseStyle(style: string): checkFunction {
return (attr: Attribute) => attr.value !== "";
case "name":
return (attr: Attribute) => attr.value !== attr.key;
/* istanbul ignore next: covered by schema validation */
default:
throw new Error(`Invalid style "${style}" for "attribute-boolean-style" rule`);
}
......
......@@ -126,11 +126,12 @@ describe("rule attribute-empty-style", () => {
it("should throw error if configured with invalid value", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "attribute-empty-style": ["error", { style: "foobar" }] },
});
expect(() => htmlvalidate.validateString("<foo></foo>")).toThrow(
`Invalid style "foobar" for "attribute-empty-style" rule`
expect(() => {
return new HtmlValidate({
rules: { "attribute-empty-style": ["error", { style: "foobar" }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/attribute-empty-style/1/style should be equal to one of the allowed values: empty, omit"`
);
});
......
import { Attribute, HtmlElement } from "../dom";
import { DOMReadyEvent } from "../event";
import { PermittedAttribute } from "../meta/element";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface Options {
style: string;
interface RuleOptions {
style: "omit" | "empty";
}
const defaults: Options = {
const defaults: RuleOptions = {
style: "omit",
};
type checkFunction = (attr: Attribute) => boolean;
export default class AttributeEmptyStyle extends Rule<void, Options> {
export default class AttributeEmptyStyle extends Rule<void, RuleOptions> {
private hasInvalidStyle: checkFunction;
public constructor(options: Partial<Options>) {
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.hasInvalidStyle = parseStyle(this.options.style);
}
public static schema(): SchemaObject {
return {
style: {
enum: ["empty", "omit"],
type: "string",
},
};
}
public documentation(): RuleDocumentation {
return {
description: "Require a specific style for attributes with empty values.",
......@@ -83,6 +92,7 @@ function parseStyle(style: string): checkFunction {
return (attr: Attribute) => attr.value !== null;
case "empty":
return (attr: Attribute) => attr.value !== "";
/* istanbul ignore next: covered by schema validation */
default:
throw new Error(`Invalid style "${style}" for "attribute-empty-style" rule`);
}
......
import { DOMTokenList } from "../dom";
import { AttributeEvent } from "../event";
import { describePattern, parsePattern, PatternName } from "../pattern";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleOptions {
pattern: PatternName;
......@@ -19,6 +19,14 @@ export default class ClassPattern extends Rule<void, RuleOptions> {
this.pattern = parsePattern(this.options.pattern);
}
public static schema(): SchemaObject {
return {
pattern: {
type: "string",
},
};
}
public documentation(): RuleDocumentation {
const pattern = describePattern(this.options.pattern);
return {
......
import { DoctypeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleContext {
style: "uppercase" | "lowercase";
......@@ -18,6 +18,15 @@ export default class DoctypeStyle extends Rule<RuleContext, RuleOptions> {
super({ ...defaults, ...options });
}
public static schema(): SchemaObject {
return {
style: {
enum: ["lowercase", "uppercase"],
type: "string",
},
};
}
public documentation(context?: RuleContext): RuleDocumentation {
const doc: RuleDocumentation = {
description: `While DOCTYPE is case-insensitive in the standard the current configuration requires a specific style.`,
......
......@@ -143,11 +143,12 @@ describe("rule element-case", () => {
it("should throw error if configured with invalid value", () => {
expect.assertions(1);
htmlvalidate = new HtmlValidate({
rules: { "element-case": ["error", { style: "foobar" }] },
});
expect(() => htmlvalidate.validateString("<foo></foo>")).toThrow(
`Invalid style "foobar" for element-case rule`
expect(() => {
return new HtmlValidate({
rules: { "element-case": ["error", { style: "foobar" }] },
});
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/element-case/1/style should be equal to one of the allowed values: lowercase, uppercase, pascalcase, camelcase"`
);
});
......
import { Location, sliceLocation } from "../context";
import { HtmlElement } from "../dom";
import { TagEndEvent, TagStartEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
import { CaseStyle, CaseStyleName } from "./helper/case-style";
interface RuleOptions {
style: CaseStyleName;
style: CaseStyleName | CaseStyleName[];
}
const defaults: RuleOptions = {
......@@ -20,6 +20,27 @@ export default class ElementCase extends Rule<void, RuleOptions> {
this.style = new CaseStyle(this.options.style, "element-case");
}
public static schema(): SchemaObject {
const styleEnum = ["lowercase", "uppercase", "pascalcase", "camelcase"];
return {
style: {
anyOf: [
{
enum: styleEnum,
type: "string",
},
{
items: {
enum: styleEnum,
type: "string",
},
type: "array",
},
],
},
};
}
public documentation(): RuleDocumentation {
return {
description: `Element tagname must be ${this.options.style}.`,
......
import { sliceLocation } from "../context";
import { TagStartEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface Context {
tagName: string;
......@@ -30,6 +30,26 @@ export default class ElementName extends Rule<Context, RuleOptions> {
this.pattern = new RegExp(this.options.pattern);
}
public static schema(): SchemaObject {
return {
blacklist: {
items: {
type: "string",
},
type: "array",
},
pattern: {
type: "string",
},
whitelist: {
items: {
type: "string",
},
type: "array",
},
};
}
public documentation(context: Context): RuleDocumentation {
return {
description: this.documentationMessages(context).join("\n"),
......
......@@ -2,9 +2,9 @@ import { sliceLocation } from "../context";
import { HtmlElement, Pattern } from "../dom";
import { DOMInternalID } from "../dom/domnode";
import { TagCloseEvent, TagReadyEvent, TagStartEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface Options {
interface RuleOptions {
allowMultipleH1: boolean;
sectioningRoots: string[];
}
......@@ -15,7 +15,7 @@ interface SectioningRoot {
h1Count: number;
}
const defaults: Options = {
const defaults: RuleOptions = {
allowMultipleH1: false,
sectioningRoots: ["dialog", '[role="dialog"]'],
};
......@@ -34,11 +34,11 @@ function extractLevel(node: HtmlElement): number | null {
}
}
export default class HeadingLevel extends Rule<void, Options> {
export default class HeadingLevel extends Rule<void, RuleOptions> {
private sectionRoots: Pattern[];
private stack: SectioningRoot[] = [];
public constructor(options: Partial<Options>) {
public constructor(options: Partial<RuleOptions>) {
super({ ...defaults, ...options });
this.sectionRoots = this.options.sectioningRoots.map((it) => new Pattern(it));
......@@ -50,6 +50,20 @@ export default class HeadingLevel extends Rule<void, Options> {
});
}
public static schema(): SchemaObject {
return {
allowMultipleH1: {
type: "boolean",
},
sectioningRoots: {
items: {
type: "string",
},
type: "array",
},
};
}
public documentation(): RuleDocumentation {
const text: string[] = [];
text.push("Headings must start at <h1> and can only increase one level at a time.");
......
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { describePattern, parsePattern, PatternName } from "../pattern";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleOptions {
pattern: PatternName;
......@@ -19,6 +19,14 @@ export default class IdPattern extends Rule<void, RuleOptions> {
this.pattern = parsePattern(this.options.pattern);
}
public static schema(): SchemaObject {
return {
pattern: {
type: "string",
},
};
}
public documentation(): RuleDocumentation {
const pattern = describePattern(this.options.pattern);
return {
......
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleOptions {
maxlength: number;
......@@ -16,6 +16,14 @@ export default class LongTitle extends Rule<void, RuleOptions> {
this.maxlength = this.options.maxlength;
}
public static schema(): SchemaObject {
return {
maxlength: {
type: "number",
},
};
}
public documentation(): RuleDocumentation {
return {
description: `Search engines truncates titles with long text, possibly down-ranking the page in the process.`,
......
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
interface RuleContext {
tagName: string;
......@@ -32,6 +32,37 @@ export default class NoAutoplay extends Rule<RuleContext, RuleOptions> {
};
}
public static schema(): SchemaObject {
return {
exclude: {
anyOf: [
{
items: {