Commits (19)
# html-validate changelog
## [4.9.0](https://gitlab.com/html-validate/html-validate/compare/v4.8.0...v4.9.0) (2021-04-04)
### Features
- add rule option schemas ([f88f0da](https://gitlab.com/html-validate/html-validate/commit/f88f0da04fa674e494dd2d25e8b997c06161a73f))
- **rules:** validate rule configuration ([5ab6a21](https://gitlab.com/html-validate/html-validate/commit/5ab6a21bc5cac30676ca9334bd3d68c1cad73f45))
### Bug Fixes
- **config:** validate preset configurations ([dca9fc3](https://gitlab.com/html-validate/html-validate/commit/dca9fc3fb60da5f88668a66584b9c5965e26d5c6))
- **error:** present original json for configuration errors ([23a50f3](https://gitlab.com/html-validate/html-validate/commit/23a50f33ddbb40c430ccdfb73195a3b76b335766))
- **meta:** memory leak when loading meta table ([940ca4e](https://gitlab.com/html-validate/html-validate/commit/940ca4e1759fd22c4e6b29267329c40cd3d7561e)), closes [#106](https://gitlab.com/html-validate/html-validate/issues/106)
## [4.8.0](https://gitlab.com/html-validate/html-validate/compare/v4.7.1...v4.8.0) (2021-03-28)
### Features
......
......@@ -148,6 +148,50 @@ class MyRule extends Rule<void, RuleOptions> {
}
```
### Options validation
If the optional `schema()` function is implemented is should return [JSON schema](https://json-schema.org/learn/getting-started-step-by-step.html) for the options interface.
<div class="alert alert-info">
<i class="fa fa-info-circle" aria-hidden="true"></i>
<strong>Note</strong>
<p>Note the function must be <code>static</code> as it will be called before the instance is created, i.e. no unvalidated options will ever touch the rule implementation.</p>
</div>
The object is merged into the `properties` object of a boilerplate object schema.
```typescript
class MyRule extends Rule<void, RuleOptions> {
public static schema(): SchemaObject {
return {
text: {
type: "string",
},
};
}
}
```
Given the above schema users will receive errors such as following:
```plaintext
A configuration error was found in ".htmlvalidate.json":
TYPE should be string
3 | "elements": ["html5"],
4 | "rules": {
> 5 | "my-rule": ["error", {"text": 12 }]
| ^^ 👈🏽 type should be string
6 | }
7 | }
8 |
```
Schema validation will help both the user and the rule author:
- The user will get a descriptive errors message including details of which configuration file and where the error occured.
- The rule author will not have to worry about the data the `options` parameter, i.e. it can safely be assumed each property has the proper datatypes and other restrictions imposed by the schema.
## Cache
Expensive operations on `DOMNode` can be cached using the {@link dev/cache cache API}.
......
......@@ -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">
......
This diff is collapsed.
{
"name": "html-validate",
"version": "4.8.0",
"version": "4.9.0",
"description": "html linter",
"keywords": [
"html",
......@@ -97,9 +97,9 @@
"prompts": "^2.0.0"
},
"devDependencies": {
"@babel/core": "7.13.13",
"@babel/core": "7.13.14",
"@babel/preset-env": "7.13.12",
"@commitlint/cli": "12.0.1",
"@commitlint/cli": "12.1.1",
"@html-validate/commitlint-config": "1.3.1",
"@html-validate/eslint-config": "4.1.0",
"@html-validate/eslint-config-jest": "4.0.0",
......@@ -108,6 +108,7 @@
"@html-validate/prettier-config": "1.1.0",
"@html-validate/semantic-release-config": "1.2.6",
"@lodder/grunt-postcss": "3.0.0",
"@types/babar": "0.2.0",
"@types/babel__code-frame": "7.0.2",
"@types/estree": "0.0.47",
"@types/glob": "7.1.3",
......@@ -116,8 +117,9 @@
"@types/json-merge-patch": "0.0.5",
"@types/minimist": "1.2.1",
"@types/node": "11.15.50",
"@types/prompts": "2.0.9",
"@types/prompts": "2.0.10",
"autoprefixer": "10.2.5",
"babar": "0.2.0",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
"canonical-path": "1.0.0",
......@@ -137,7 +139,7 @@
"grunt-contrib-copy": "1.0.0",
"grunt-sass": "3.1.0",
"highlight.js": "10.7.1",
"husky": "5.2.0",
"husky": "6.0.0",
"jest": "26.6.3",
"jest-diff": "26.6.2",
"jquery": "3.6.0",
......@@ -146,7 +148,7 @@
"marked": "2.0.1",
"minimatch": "3.0.4",
"npm-pkg-lint": "1.3.0",
"postcss": "8.2.8",
"postcss": "8.2.9",
"prettier": "2.2.1",
"pretty-format": "26.6.2",
"sass": "1.32.8",
......
#!/usr/bin/env node
/* eslint-disable node/shebang, no-console */
const { CLI } = require("..");
const babar = require("babar");
function iteration() {
if (global.gc) {
global.gc();
}
const cli = new CLI();
const htmlValidate = cli.getValidator();
htmlValidate.validateString("<!DOCTYPE html><html><head></head><body></body></html>");
}
function percent(cur, prev) {
const k = cur / prev;
const p = (k - 1) * 1000;
return p > 0 ? Math.floor(p) / 10 : Math.ceil(p) / 10;
}
const numCycles = process.argv.length > 2 ? Number(process.argv[2]) : 5000;
const journal = [];
for (let cycle = 0; cycle <= numCycles; cycle++) {
iteration();
const { heapTotal, heapUsed } = process.memoryUsage();
if (cycle % 200 === 0) {
const previousTotal = journal.length > 0 ? journal[journal.length - 1].heapTotal : 1;
const previousUsed = journal.length > 0 ? journal[journal.length - 1].heapUsed : 1;
journal.push({
cycle,
heapTotal,
"heapTotal (%)": percent(heapTotal, previousTotal),
heapUsed,
"heapUsed (%)": percent(heapUsed, previousUsed),
});
}
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(`${cycle} / ${numCycles} iterations done, ${heapTotal} heap used`);
}
process.stdout.write("\n");
console.table(journal);
const values = journal.map((it) => it.heapTotal);
console.log("min:", Math.min(...values), "max:", Math.max(...values));
const heapTotal = journal.map((it, i) => [i, it.heapTotal / 1024 ** 2]);
const chartTotal = babar(heapTotal, {
caption: "Heap total (MB per 100 runs)",
color: "green",
width: 100,
height: 20,
minY: 0,
yFractions: 0,
});
console.log(chartTotal);
const heapUsed = journal.map((it, i) => [i, it.heapUsed / 1024 ** 2]);
const chartUsed = babar(heapUsed, {
caption: "Heap used (MB per 100 runs)",
color: "green",
width: 100,
height: 20,
minY: 0,
yFractions: 0,
});
console.log(chartUsed);
#!/bin/sh
if ! type jq > /dev/null; then
echo "$0: jq not found" > /dev/stderr
echo > /dev/stderr
echo "This script requires \"jq\" to be installed, see installation instructions at" > /dev/stderr
echo > /dev/stderr
echo " https://stedolan.github.io/jq/download/ " > /dev/stderr
exit 1
fi
if [ $# -lt 1 -o $# -gt 2 ]; then
echo "$0: usage: rule-options-schema FILENAME [INTERFACE]" > /dev/stderr
exit 1
fi
filename="$1"
interface="${2-RuleOptions}"
if [ ! -e "$filename" ]; then
echo "$0: ${filename}: file not found" > /dev/stderr
exit 1
fi
(
echo "class Schema {"
echo "public static schema(): SchemaObject {"
echo -n "\treturn ";
npx typescript-json-schema \
tsconfig.json "${interface}" \
--no-refs \
--include "${filename}" |
jq -M .properties
echo "}"
echo "}"
) | npx prettier --parser typescript | head -n -1 | tail -n +2
......@@ -16,6 +16,9 @@ jest.mock("ajv", () => {
/* always valid */
return () => true;
}
public getSchema(): undefined {
return undefined;
}
public addMetaSchema(): void {
/* do nothing */
}
......
......@@ -9,7 +9,10 @@ import { MetaCopyableProperty, MetaDataTable } from "../meta/element";
import { Plugin } from "../plugin";
import schema from "../schema/config.json";
import { TransformContext, Transformer, TRANSFORMER_API } from "../transform";
import { ConfigData, RuleOptions, TransformMap } from "./config-data";
import { requireUncached } from "../utils";
import bundledRules from "../rules";
import { Rule } from "../rule";
import { ConfigData, RuleConfig, RuleOptions, TransformMap } from "./config-data";
import defaultConfig from "./default";
import { ConfigError } from "./error";
import { parseSeverity, Severity } from "./severity";
......@@ -64,12 +67,8 @@ function mergeInternal(base: ConfigData, rhs: ConfigData): ConfigData {
function loadFromFile(filename: string): ConfigData {
let json;
try {
/* remove cached copy so we always load a fresh copy, important for editors
* which keep a long-running instance of [[HtmlValidate]] around. */
delete require.cache[require.resolve(filename)];
/* load using require as it can process both js and json */
json = require(filename); // eslint-disable-line import/no-dynamic-require
json = requireUncached(filename);
} catch (err) {
throw new ConfigError(`Failed to read configuration from "${filename}"`, err);
}
......@@ -139,17 +138,26 @@ export class Config {
*
* Throws SchemaValidationError if invalid.
*/
public static validate(options: ConfigData, filename: string | null = null): void {
const valid = validator(options);
public static validate(configData: ConfigData, filename: string | null = null): void {
const valid = validator(configData);
if (!valid) {
throw new SchemaValidationError(
filename,
`Invalid configuration`,
options,
configData,
schema,
validator.errors ?? []
);
}
if (configData.rules) {
const normalizedRules = Config.getRulesObject(configData.rules);
for (const [ruleId, [, ruleOptions]] of normalizedRules.entries()) {
const cls = bundledRules[ruleId];
const path = `/rules/${ruleId}/1`;
Rule.validateOptions(cls, ruleId, path, ruleOptions, filename, configData);
}
}
}
/**
......@@ -322,8 +330,12 @@ export class Config {
* Get all configured rules, their severity and options.
*/
public getRules(): Map<string, [Severity, RuleOptions]> {
return Config.getRulesObject(this.config.rules ?? {});
}
private static getRulesObject(src: RuleConfig): Map<string, [Severity, RuleOptions]> {
const rules = new Map<string, [Severity, RuleOptions]>();
for (const [ruleId, data] of Object.entries(this.config.rules ?? {})) {
for (const [ruleId, data] of Object.entries(src)) {
let options = data;
if (!Array.isArray(options)) {
options = [options, {}];
......@@ -362,6 +374,7 @@ export class Config {
/* builtin presets */
for (const [name, config] of Object.entries(Presets)) {
Config.validate(config, name);
configs.set(name, config);
}
......@@ -370,6 +383,8 @@ export class Config {
for (const [name, config] of Object.entries(plugin.configs || {})) {
if (!config) continue;
Config.validate(config, name);
/* add configuration with name provided by plugin */
configs.set(`${plugin.name}:${name}`, config);
......
{
"foo": [1, 2, 3],
"bar": "baz"
}
import stripAnsi = require("strip-ansi");
import fs from "fs";
import path from "path";
import Ajv, { SchemaObject } from "ajv";
import stripAnsi from "strip-ansi";
import { MetaElement } from "../meta/element";
import { MetaTable } from "../meta/table";
import { SchemaValidationError } from "./schema-validation-error";
......@@ -22,3 +25,83 @@ it("SchemaValidationError should pretty-print validation errors", () => {
}
}
});
describe("prettyError()", () => {
const ajv = new Ajv({ strict: true, strictTuples: true, strictTypes: true });
const schema: SchemaObject = {
type: "string",
};
const validator = ajv.compile(schema);
const filename = path.join(__dirname, "__fixtures__", "invalid.json");
const data = JSON.parse(fs.readFileSync(filename, "utf-8"));
validator(data);
const errors = validator.errors ?? [];
it("should contain original formatting", () => {
expect.assertions(1);
const error = new SchemaValidationError(filename, "Mock error", data, schema, errors);
expect(stripAnsi(error.prettyError())).toMatchInlineSnapshot(`
"TYPE should be string
> 1 | {
| ^
> 2 | \\"foo\\": [1, 2, 3],
| ^^^^^^^^^^^^^^^^^^^
> 3 | \\"bar\\": \\"baz\\"
| ^^^^^^^^^^^^^^^^^^^
> 4 | }
| ^^ 👈🏽 type should be string
5 |"
`);
});
it("should handle invalid file", () => {
expect.assertions(1);
const error = new SchemaValidationError("invalid-file", "Mock error", data, schema, errors);
expect(stripAnsi(error.prettyError())).toMatchInlineSnapshot(`
"TYPE should be string
> 1 | {
| ^
> 2 | \\"foo\\": [
| ^^^^^^^^^^
> 3 | 1,
| ^^^^^^^^^^
> 4 | 2,
| ^^^^^^^^^^
> 5 | 3
| ^^^^^^^^^^
> 6 | ],
| ^^^^^^^^^^
> 7 | \\"bar\\": \\"baz\\"
| ^^^^^^^^^^
> 8 | }
| ^^ 👈🏽 type should be string"
`);
});
it("should handle missing filename", () => {
expect.assertions(1);
const error = new SchemaValidationError(null, "Mock error", data, schema, errors);
expect(stripAnsi(error.prettyError())).toMatchInlineSnapshot(`
"TYPE should be string
> 1 | {
| ^
> 2 | \\"foo\\": [
| ^^^^^^^^^^
> 3 | 1,
| ^^^^^^^^^^
> 4 | 2,
| ^^^^^^^^^^
> 5 | 3
| ^^^^^^^^^^
> 6 | ],
| ^^^^^^^^^^
> 7 | \\"bar\\": \\"baz\\"
| ^^^^^^^^^^
> 8 | }
| ^^ 👈🏽 type should be string"
`);
});
});
import fs from "fs";
import betterAjvErrors from "@sidvind/better-ajv-errors";
import { ErrorObject, SchemaObject } from "ajv";
import { UserError } from "./user-error";
......@@ -33,9 +34,19 @@ export class SchemaValidationError extends UserError {
}
public prettyError(): string {
const json = this.getRawJSON();
return betterAjvErrors(this.schema, this.obj, this.errors, {
format: "cli",
indent: 2,
json,
});
}
private getRawJSON(): string | null {
if (this.filename && fs.existsSync(this.filename)) {
return fs.readFileSync(this.filename, "utf-8");
} else {
return null;
}
}
}
......@@ -5,6 +5,7 @@ import jsonMergePatch from "json-merge-patch";
import { HtmlElement } from "../dom";
import { SchemaValidationError, UserError } from "../error";
import { SchemaValidationPatch } from "../plugin";
import { requireUncached } from "../utils";
import schema from "../schema/elements.json";
import {
ElementTable,
......@@ -139,13 +140,8 @@ export class MetaTable {
*/
public loadFromFile(filename: string): void {
try {
/* remove cached copy so we always load a fresh copy, important for
* editors which keep a long-running instance of [[HtmlValidate]]
* around. */
delete require.cache[require.resolve(filename)];
/* load using require as it can process both js and json */
const data = require(filename); // eslint-disable-line import/no-dynamic-require
const data = requireUncached(filename);
this.loadFromObject(data, filename);
} catch (err) {
if (err instanceof SchemaValidationError) {
......
import path from "path";
import { Config, Severity } from "./config";
import { Config, ConfigData, Severity } from "./config";
import { Location } from "./context";
import { HtmlElement, NodeClosed } from "./dom";
import { Event, EventCallback, TagEndEvent, TagStartEvent } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { Rule, ruleDocumentationUrl, IncludeExcludeOptions } from "./rule";
import { Rule, ruleDocumentationUrl, IncludeExcludeOptions, SchemaObject } from "./rule";
import { MetaTable } from "./meta";
interface RuleContext {
......@@ -343,6 +343,138 @@ describe("rule base class", () => {
});
});
describe("isEnabled()", () => {
it.each`
enabled | severity | result
${1} | ${0} | ${false}
${1} | ${1} | ${true}
${1} | ${2} | ${true}
${0} | ${0} | ${false}
${0} | ${1} | ${false}
${0} | ${2} | ${false}
`("enabled=$enabled, severity=$severity should be $result", ({ enabled, severity, result }) => {
expect.assertions(1);
const rule = new MockRule();
rule.setEnabled(Boolean(enabled));
rule.setServerity(severity);
expect(rule.isEnabled()).toEqual(result);
});
});
it("should not be deprecated by default", () => {
expect.assertions(1);
const rule = new MockRule();
expect(rule.deprecated).toBeFalsy();
});
it("should be off by default", () => {
expect.assertions(1);
const rule = new MockRule();
expect(rule.getSeverity()).toEqual(Severity.DISABLED);
});
describe("validateOptions()", () => {
class MockRuleSchema extends Rule {
public static schema(): SchemaObject {
return {
foo: {
type: "number",
},
};
}
public setup(): void {
/* do nothing */
}
}
it("should throw validation error if options does not match schema", () => {
expect.assertions(1);
const options = { foo: "bar" };
const config: ConfigData = {
rules: {
"mock-rule-invalid": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-invalid/1";
expect(() => {
return Rule.validateOptions(
MockRuleSchema,
"mock-rule-invalid",
jsonPath,
options,
"inline",
config
);
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/mock-rule-invalid/1/foo: type should be number"`
);
});
it("should not throw validation error if options matches schema", () => {
expect.assertions(1);
const options = { foo: 12 };
const config: ConfigData = {
rules: {
"mock-rule-valid": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-valid/1";
expect(() => {
return Rule.validateOptions(
MockRuleSchema,
"mock-rule-valid",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
it("should handle rules without schema", () => {
expect.assertions(1);
const options = { foo: "spam" };
const config: ConfigData = {
rules: {
"mock-rule-no-schema": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-no-schema/1";
expect(() => {
return Rule.validateOptions(
MockRule,
"mock-rule-no-schema",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
it("should handle missing class", () => {
expect.assertions(1);
const options = { foo: "spam" };
const config: ConfigData = {
rules: {
"mock-rule-undefined": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-undefined/1";
expect(() => {
return Rule.validateOptions(
undefined,
"mock-rule-undefined",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
});
it("ruleDocumentationUrl() should return URL to rule documentation", () => {
expect.assertions(1);
expect(ruleDocumentationUrl("src/rules/foo.ts")).toEqual(
......
import path from "path";
import { Severity } from "./config";
import Ajv, { ErrorObject, SchemaObject, ValidateFunction } from "ajv";
import { ConfigData, Severity } from "./config";
import { Location } from "./context";
import { DOMNode } from "./dom";
import { Event, ListenEventMap } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { MetaTable, MetaLookupableProperty } from "./meta";
import { SchemaValidationError } from "./error";
export { SchemaObject } from "ajv";
const homepage = require("../package.json").homepage;
......@@ -14,6 +18,9 @@ const remapEvents: Record<string, string> = {
"tag:close": "tag:end",
};
const ajv = new Ajv({ strict: true, strictTuples: true, strictTypes: true });
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
export interface RuleDocumentation {
description: string;
url?: string;
......@@ -21,6 +28,7 @@ export interface RuleDocumentation {
export interface RuleConstructor<T, U> {
new (options?: any): Rule<T, U>;
schema(): SchemaObject | null | undefined;
}
export interface IncludeExcludeOptions {
......@@ -28,6 +36,30 @@ export interface IncludeExcludeOptions {
exclude: string[] | null;
}
/**
* Get (cached) schema validator for rule options.
*
* @param ruleId - Rule ID used as key for schema lookups.
* @param properties - Uncompiled schema.
*/
function getSchemaValidator(ruleId: string, properties: SchemaObject): ValidateFunction {
const $id = `rule/${ruleId}`;
const cached = ajv.getSchema($id);
if (cached) {
return cached;
}
const schema = {
$id,
type: "object",
additionalProperties: false,
properties,
};
return ajv.compile(schema);
}
export abstract class Rule<ContextType = void, OptionsType = void> {
private reporter: Reporter;
private parser: Parser;
......@@ -148,6 +180,17 @@ export abstract class Rule<ContextType = void, OptionsType = void> {
return this.meta.getTagsDerivedFrom(tagName);
}
/**
* JSON schema for rule options.
*
* Rules should override this to return an object with JSON schema to validate
* rule options. If `null` or `undefined` is returned no validation is
* performed.
*/
public static schema(): SchemaObject | null | undefined {
return null;
}
/**
* Report a new error.
*
......@@ -242,6 +285,52 @@ export abstract class Rule<ContextType = void, OptionsType = void> {
this.meta = meta;
}
/**
* Validate rule options against schema. Throws error if object does not validate.
*
* For rules without schema this function does nothing.
*
* @throws {@link SchemaValidationError}
* Thrown when provided options does not validate against rule schema.
*
* @param cls - Rule class (constructor)
* @param ruleId - Rule identifier
* @param jsonPath - JSON path from which [[options]] can be found in [[config]]
* @param options - User configured options to be validated
* @param filename - Filename from which options originated
* @param config - Configuration from which options originated
*
* @hidden
*/
public static validateOptions(
cls: RuleConstructor<unknown, unknown> | undefined,
ruleId: string,
jsonPath: string,
options: unknown,
filename: string | null,
config: ConfigData
): void {
if (!cls) {
return;
}
const schema = cls.schema();
if (!schema) {
return;
}
const isValid = getSchemaValidator(ruleId, schema);
if (!isValid(options)) {
/* istanbul ignore next: it is always set when validation fails */
const errors = isValid.errors ?? [];
const mapped = errors.map((error: ErrorObject) => {
error.dataPath = `${jsonPath}${error.dataPath}`;
return error;
});
throw new SchemaValidationError(filename, `Rule configuration error`, config, schema, mapped);
}
}
/**
* Rule setup callback.
*
......
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)", () => {
......