...
 
Commits (17)
# html-validate changelog
# [2.7.0](https://gitlab.com/html-validate/html-validate/compare/v2.6.0...v2.7.0) (2019-12-16)
### Bug Fixes
- **config:** more helpful error when user forgot to load plugin ([62bbbe5](https://gitlab.com/html-validate/html-validate/commit/62bbbe503a5674369f24cf2a7116518b64cc2146))
### Features
- **config:** configuration schema validation ([c9fe45f](https://gitlab.com/html-validate/html-validate/commit/c9fe45fe4de2c807ec9dbed8126698f2480a7135)), closes [#61](https://gitlab.com/html-validate/html-validate/issues/61)
- **dom:** allow plugins to modify element annotation ([979da57](https://gitlab.com/html-validate/html-validate/commit/979da571ab69f22519973e7deda7531fc2560237))
- **dom:** allow plugins to modify element metadata ([cbe3e78](https://gitlab.com/html-validate/html-validate/commit/cbe3e78561e38b0abcef0a7d87a0e2aa6897ccb3)), closes [#62](https://gitlab.com/html-validate/html-validate/issues/62)
- **elements:** make schema publicly accessible ([bcab9e4](https://gitlab.com/html-validate/html-validate/commit/bcab9e4121d80fe92cdd12da84925e07e5b98297))
- **rules:** use annotated name ([1895ef4](https://gitlab.com/html-validate/html-validate/commit/1895ef4311c36cca17e8c68ebd58724df082c335))
# [2.6.0](https://gitlab.com/html-validate/html-validate/compare/v2.5.0...v2.6.0) (2019-12-12)
### Bug Fixes
......
......@@ -7,6 +7,7 @@ module.exports = new Package("html-validate-docs", [
require("dgeni-packages/nunjucks"),
require("./highlight"),
require("./inline-validate"),
require("./schema"),
])
.processor(require("./processors/rules"))
......@@ -44,7 +45,8 @@ module.exports = new Package("html-validate-docs", [
log,
readFilesProcessor,
templateFinder,
writeFilesProcessor
writeFilesProcessor,
copySchema
) {
log.level = "info";
......@@ -62,6 +64,9 @@ module.exports = new Package("html-validate-docs", [
},
];
copySchema.outputFolder = "public/schemas";
copySchema.files = ["src/schema/elements.json", "src/schema/config.json"];
writeFilesProcessor.outputFolder = "public";
})
......
const Package = require("dgeni").Package;
module.exports = new Package("schema", [])
.processor(require("./processors/copy-schema-processor"))
.factory(require("./services/copy-schema"));
const path = require("path");
const fs = require("fs");
const mkdirp = require("mkdirp");
module.exports = function copySchemaProcessor(copySchema, readFilesProcessor) {
return {
$runBefore: ["extra-docs-added"],
$process,
};
function $process() {
const root = readFilesProcessor.basePath;
const outputFolder = path.join(root, copySchema.outputFolder);
mkdirp.sync(outputFolder);
for (const src of copySchema.files) {
const name = path.basename(src);
fs.copyFileSync(path.join(root, src), path.join(outputFolder, name));
}
}
};
module.exports = function copySchema() {
return {
outputFolder: "",
files: [],
};
};
{
"name": "html-validate",
"version": "2.6.0",
"version": "2.7.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
......@@ -2039,21 +2039,6 @@
}
}
},
"@babel/runtime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0.tgz",
"integrity": "sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==",
"requires": {
"regenerator-runtime": "^0.12.0"
},
"dependencies": {
"regenerator-runtime": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz",
"integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg=="
}
}
},
"@babel/template": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.2.2.tgz",
......@@ -4372,6 +4357,46 @@
}
}
},
"@sidvind/better-ajv-errors": {
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-0.6.9.tgz",
"integrity": "sha512-OPdSVMjy4xR/fnN3JBVu4xHyzmRBlrIWAzgdGiVdiGwPypucoh1yN4bFy5FpE+261NF1CwR2CGYgPiMwLVX+zQ==",
"requires": {
"@babel/code-frame": "^7.0.0",
"chalk": "^2.4.1",
"json-to-ast": "^2.0.3",
"jsonpointer": "^4.0.1",
"leven": "^3.1.0"
},
"dependencies": {
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"requires": {
"color-convert": "^1.9.0"
}
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"@sindresorhus/is": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
......@@ -6040,48 +6065,6 @@
"integrity": "sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==",
"dev": true
},
"better-ajv-errors": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-0.6.2.tgz",
"integrity": "sha512-0tZRYqH9wvfHlWsBcgoqf3y8CABjdjKe2P+uVIPuxXE9iM7R51r1QrRse0v9clnOpiql3BwXGN3pQiXPmAHjdg==",
"requires": {
"@babel/code-frame": "^7.0.0",
"@babel/runtime": "^7.0.0",
"chalk": "^2.4.1",
"core-js": "^2.5.7",
"json-to-ast": "^2.0.3",
"jsonpointer": "^4.0.1",
"leven": "^2.1.0"
},
"dependencies": {
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"requires": {
"color-convert": "^1.9.0"
}
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"binary-extensions": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.10.0.tgz",
......@@ -7704,7 +7687,8 @@
"core-js": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz",
"integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A=="
"integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==",
"dev": true
},
"core-js-compat": {
"version": "3.4.8",
......@@ -11808,9 +11792,9 @@
"dev": true
},
"highlight.js": {
"version": "9.17.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.17.0.tgz",
"integrity": "sha512-PyO7FK7z8ZC7FqBlmAxm4d+1DYaoS6+uaxt9KGkyP1AnmGRLnWmNod1yp9BFjUyHoDF00k+V57gF6X9ifY7f/A==",
"version": "9.17.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.17.1.tgz",
"integrity": "sha512-TA2/doAur5Ol8+iM3Ov7qy3jYcr/QiJ2eDTdRF4dfbjG7AaaB99J5G+zSl11ljbl6cIcahgPY6SKb3sC3EJ0fw==",
"dev": true,
"requires": {
"handlebars": "^4.5.3"
......@@ -15260,9 +15244,9 @@
"dev": true
},
"leven": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz",
"integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA="
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="
},
"levn": {
"version": "0.3.0",
......
{
"name": "html-validate",
"version": "2.6.0",
"version": "2.7.0",
"description": "html linter",
"keywords": [
"html",
......@@ -131,9 +131,9 @@
},
"dependencies": {
"@babel/code-frame": "^7.0.0",
"@sidvind/better-ajv-errors": "^0.6.9",
"acorn-walk": "^7.0.0",
"ajv": "^6.10.0",
"better-ajv-errors": "^0.6.2",
"chalk": "^3.0.0",
"deepmerge": "^4.0.0",
"eslint": "^6.0.0",
......@@ -188,7 +188,7 @@
"grunt-contrib-copy": "1.0.0",
"grunt-postcss": "0.9.0",
"grunt-sass": "3.1.0",
"highlight.js": "9.17.0",
"highlight.js": "9.17.1",
"husky": "3.1.0",
"jest": "24.9.0",
"jest-diff": "24.9.0",
......@@ -197,6 +197,7 @@
"lint-staged": "9.5.0",
"load-grunt-tasks": "5.1.0",
"minimatch": "3.0.4",
"mkdirp": "0.5.1",
"prettier": "1.19.1",
"sass": "1.23.7",
"semantic-release": "15.13.31",
......
......@@ -103,8 +103,12 @@ function renameStdin(report: Report, filename: string): void {
}
function handleValidationError(err: SchemaValidationError): void {
const filename = path.relative(process.cwd(), err.filename);
console.log(chalk.red(`A configuration error was found in "${filename}":`));
if (err.filename) {
const filename = path.relative(process.cwd(), err.filename);
console.log(chalk.red(`A configuration error was found in "${filename}":`));
} else {
console.log(chalk.red(`A configuration error was found:`));
}
if (console.group) console.group();
{
console.log(err.prettyError());
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`config transformSource() should throw error when trying to load garbage as transformer 1`] = `"Failed to load transformer \\"mock-garbage\\": Module is not a valid transformer."`;
exports[`config transformSource() should throw error when trying to load named transform from plugin without any 1`] = `"Failed to load transformer \\"mock-plugin-notransform:named\\": Plugin does not expose any transformer"`;
exports[`config transformSource() should throw error when trying to load unnamed transform from plugin without any 1`] = `"Failed to load transformer \\"mock-plugin-notransform\\": Plugin does not expose any transformer"`;
exports[`config transformSource() should throw helpful error when trying to load unregistered plugin as transformer 1`] = `"Failed to load transformer \\"mock-plugin-unregistered\\": Module is not a valid transformer. This looks like a plugin, did you forget to load the plugin first?"`;
exports[`config transformSource() should throw sane error when transformer fails 1`] = `"When transforming \\"/path/to/test.foo\\": Failed to frobnicate a baz"`;
exports[`config transformSource() should throw sane error when transformer fails to load 1`] = `"Failed to load transformer \\"missing-transformer\\""`;
......@@ -10,6 +10,19 @@ declare module "./config-data" {
}
}
jest.mock("ajv", () => {
class MockAjv {
public compile(): () => boolean {
/* always valid */
return () => true;
}
public addMetaSchema(): void {
/* do nothing */
}
}
return MockAjv;
});
class MockConfig {
public static empty(): Config {
return Config.empty();
......
import fs from "fs";
import path from "path";
import { Source } from "../context";
import { SchemaValidationError } from "../error";
import { UserError } from "../error/user-error";
import { Transformer, TRANSFORMER_API } from "../transform";
import { Config } from "./config";
......@@ -80,6 +81,17 @@ describe("config", () => {
);
});
it("should throw error if file is invalid", () => {
expect(() =>
Config.fromObject({
rules: "spam",
} as any)
).toThrow("Invalid configuration: /rules: type should be object");
expect(() => Config.fromFile("invalid-file.json")).toThrow(
'Failed to read configuration from "invalid-file.json"'
);
});
describe("merge()", () => {
it("should merge two configs", () => {
const a = Config.fromObject({ rules: { foo: 1 } });
......@@ -99,7 +111,7 @@ describe("config", () => {
describe("getRules()", () => {
it("should handle when config is missing rules", () => {
const config = Config.fromObject({ rules: null });
const config = Config.fromObject({ rules: undefined });
expect(config.get().rules).toEqual({});
expect(config.getRules()).toEqual(new Map());
});
......@@ -142,16 +154,6 @@ describe("config", () => {
]);
});
it("should throw on invalid severity", () => {
const fn = Config.fromObject as (options: any) => Config;
const config = fn({
rules: {
bar: "foo",
},
});
expect(() => config.getRules()).toThrow('Invalid severity "foo"');
});
it("should retain options", () => {
const config = Config.fromObject({
rules: {
......@@ -166,11 +168,6 @@ describe("config", () => {
["baz", [Severity.WARN, {}]],
]);
});
it("should handle when rules are unset", () => {
const config = Config.fromObject({ rules: null });
expect(Array.from(config.getRules().entries())).toEqual([]);
});
});
describe("fromFile()", () => {
......@@ -643,6 +640,28 @@ describe("config", () => {
});
expect(() => config.init()).toThrowErrorMatchingSnapshot();
});
it("should throw error when trying to load garbage as transformer", () => {
jest.mock("mock-garbage", () => "foobar", { virtual: true });
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "mock-garbage",
},
});
expect(() => config.init()).toThrowErrorMatchingSnapshot();
});
it("should throw helpful error when trying to load unregistered plugin as transformer", () => {
jest.mock("mock-plugin-unregistered", () => ({ transformer: {} }), {
virtual: true,
});
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "mock-plugin-unregistered",
},
});
expect(() => config.init()).toThrowErrorMatchingSnapshot();
});
});
describe("transformFilename()", () => {
......@@ -706,8 +725,8 @@ describe("config", () => {
it("should handle unset fields", () => {
const config = Config.fromObject({
plugins: null,
transform: null,
plugins: undefined,
transform: undefined,
});
expect(() => {
config.init();
......@@ -737,4 +756,48 @@ describe("config", () => {
expect(config.findRootDir()).toEqual(process.cwd());
spy.mockRestore();
});
describe("schema validation", () => {
describe("valid", () => {
it.each([
["empty", {}],
["root true", { root: true }],
["root false", { root: false }],
["extends empty", { extends: [] }],
["extends string", { extends: ["foo", "bar", "baz"] }],
["elements empty", { elements: [] }],
["elements string", { elements: ["foo", "bar", "baz"] }],
["plugins empty", { plugins: [] }],
["plugins string", { plugins: ["foo", "bar", "baz"] }],
["transform empty", { transform: {} }],
["transform patterns", { transform: { "^foo$": "bar" } }],
["rules empty", { rules: {} }],
["rules with numeric severity", { rules: { foo: 0, bar: 1, baz: 2 } }],
["rules with severity", { rules: { a: "off", b: "warn", c: "error" } }],
["rules with missing options", { rules: { foo: ["error"] } }],
["rules with options", { rules: { foo: ["error", { spam: "ham" }] } }],
] as Array<[string, any]>)("%s", (_, config: any) => {
expect(() => Config.validate(config)).not.toThrow();
});
});
describe("invalid", () => {
it.each([
["root garbage", { root: "asdf" }],
["extends garbage", { extends: "asdf" }],
["extends invalid", { extends: [1] }],
["elements garbage", { elements: "asdf" }],
["elements invalid", { elements: [1] }],
["plugins garbage", { plugins: "asdf" }],
["plugins invalid", { plugins: [1] }],
["transform garbage", { transform: "asdf" }],
["transform invalid", { transform: { foo: 1 } }],
["rules with invalid numeric severity", { rules: { foo: -1, bar: 3 } }],
["rules with invalid severity", { rules: { foo: "spam" } }],
["additional property", { foo: "bar" }],
] as Array<[string, any]>)("%s", (_, config: any) => {
expect(() => Config.validate(config)).toThrow(SchemaValidationError);
});
});
});
});
import Ajv from "ajv";
import deepmerge from "deepmerge";
import fs from "fs";
import path from "path";
import { Source } from "../context";
import { NestedError } from "../error";
import { NestedError, SchemaValidationError } from "../error";
import { MetaTable } from "../meta";
import { MetaDataTable } from "../meta/element";
import { Plugin } from "../plugin";
import schema from "../schema/config.json";
import { TransformContext, Transformer, TRANSFORMER_API } from "../transform";
import { ConfigData, TransformMap } from "./config-data";
import defaultConfig from "./default";
......@@ -30,6 +32,10 @@ const recommended = require("./recommended");
const document = require("./document");
let rootDirCache: string = null;
const ajv = new Ajv({ jsonPointers: true });
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
const validator = ajv.compile(schema);
function overwriteMerge<T>(a: T[], b: T[]): T[] {
return b;
}
......@@ -106,7 +112,11 @@ export class Config {
/**
* Create configuration from object.
*/
public static fromObject(options: ConfigData): Config {
public static fromObject(
options: ConfigData,
filename: string | null = null
): Config {
Config.validate(options, filename);
return new Config(options);
}
......@@ -121,7 +131,25 @@ export class Config {
*/
public static fromFile(filename: string): Config {
const configdata = loadFromFile(filename);
return new Config(configdata);
return Config.fromObject(configdata, filename);
}
/**
* Validate configuration data.
*
* Throws SchemaValidationError if invalid.
*/
public static validate(options: ConfigData, filename?: string): void {
const valid = validator(options);
if (!valid) {
throw new SchemaValidationError(
filename,
`Invalid configuration`,
options,
schema,
validator.errors
);
}
}
/**
......@@ -485,51 +513,102 @@ export class Config {
* - Named transformers from plugins.
* - Unnamed transformer from plugin.
* - Standalone modules (local or node_modules)
*
* @param name - Key from configuration
*/
private getTransformFunction(name: string): Transformer {
/* try to match a named transformer from plugin */
const match = name.match(/(.*):(.*)/);
if (match) {
const [, pluginName, key] = match;
const plugin = this.plugins.find(cur => cur.name === pluginName);
if (!plugin) {
throw new ConfigError(
`No plugin named "${pluginName}" has been loaded`
);
}
if (!plugin.transformer) {
throw new ConfigError(`Plugin does not expose any transformer`);
}
if (typeof plugin.transformer === "function") {
throw new ConfigError(
`Transformer "${name}" refers to named transformer but plugin exposes only unnamed, use "${pluginName}" instead.`
);
}
if (!plugin.transformer[key]) {
throw new ConfigError(
`Plugin "${pluginName}" does not expose a transformer named "${key}".`
);
}
return plugin.transformer[key];
return this.getNamedTransformerFromPlugin(name, pluginName, key);
}
/* try to match an unnamed transformer from plugin */
const plugin = this.plugins.find(cur => cur.name === name);
if (plugin) {
if (!plugin.transformer) {
throw new ConfigError(`Plugin does not expose any transformer`);
}
if (typeof plugin.transformer !== "function") {
return this.getUnnamedTransformerFromPlugin(name, plugin);
}
/* assume transformer refers to a regular module */
return this.getTransformerFromModule(name);
}
/**
* @param name - Original name from configuration
* @param pluginName - Name of plugin
* @param key - Name of transform (from plugin)
*/
private getNamedTransformerFromPlugin(
name: string,
pluginName: string,
key: string
): Transformer {
const plugin = this.plugins.find(cur => cur.name === pluginName);
if (!plugin) {
throw new ConfigError(`No plugin named "${pluginName}" has been loaded`);
}
if (!plugin.transformer) {
throw new ConfigError(`Plugin does not expose any transformer`);
}
if (typeof plugin.transformer === "function") {
throw new ConfigError(
`Transformer "${name}" refers to named transformer but plugin exposes only unnamed, use "${pluginName}" instead.`
);
}
if (!plugin.transformer[key]) {
throw new ConfigError(
`Plugin "${pluginName}" does not expose a transformer named "${key}".`
);
}
return plugin.transformer[key];
}
/**
* @param name - Original name from configuration
* @param plugin - Plugin instance
*/
private getUnnamedTransformerFromPlugin(
name: string,
plugin: Plugin
): Transformer {
if (!plugin.transformer) {
throw new ConfigError(`Plugin does not expose any transformer`);
}
if (typeof plugin.transformer !== "function") {
throw new ConfigError(
`Transformer "${name}" refers to unnamed transformer but plugin exposes only named.`
);
}
return plugin.transformer;
}
private getTransformerFromModule(name: string): Transformer {
/* expand <rootDir> */
const moduleName = name.replace("<rootDir>", this.rootDir);
// eslint-disable-next-line security/detect-non-literal-require
const fn = require(moduleName);
/* sanity check */
if (typeof fn !== "function") {
/* this is not a proper transformer, is it a plugin exposing a transformer? */
if (fn.transformer) {
throw new ConfigError(
`Transformer "${name}" refers to unnamed transformer but plugin exposes only named.`
`Module is not a valid transformer. This looks like a plugin, did you forget to load the plugin first?`
);
}
return plugin.transformer;
throw new ConfigError(`Module is not a valid transformer.`);
}
/* assume transformer refers to a regular module */
// eslint-disable-next-line security/detect-non-literal-require
return require(name.replace("<rootDir>", this.rootDir));
return fn;
}
protected findRootDir(): string {
......
export { Source } from "./source";
export { Source, ProcessElementContext } from "./source";
export { Location, sliceLocation } from "./location";
export { Context, ContentModel } from "./context";
import { HtmlElement } from "../dom";
import { MetaElement } from "../meta";
import { AttributeData } from "../parser";
export type ProcessAttributeCallback = (
this: {},
attr: AttributeData
) => Iterable<AttributeData>;
export type ProcessElementCallback = (node: HtmlElement) => void;
export interface ProcessElementContext {
getMetaFor(tagName: string): MetaElement;
}
export type ProcessElementCallback = (
this: ProcessElementContext,
node: HtmlElement
) => void;
export interface SourceHooks {
/**
......
......@@ -2,7 +2,7 @@ import { Attribute, DOMTree, HtmlElement, NodeClosed, NodeType } from ".";
import { Config } from "../config";
import { Location, Source } from "../context";
import { Token, TokenType } from "../lexer";
import { MetaData, MetaTable } from "../meta";
import { MetaData, MetaElement, MetaTable } from "../meta";
import { Parser } from "../parser";
import { processAttribute } from "../transform/mocks/attribute";
import { DynamicValue } from "./dynamic-value";
......@@ -131,6 +131,21 @@ describe("HtmlElement", () => {
});
});
describe("annotatedName", () => {
it("should use annotation if set", () => {
expect.assertions(1);
const node = new HtmlElement("my-element");
node.setAnnotation("my annotation");
expect(node.annotatedName).toEqual("my annotation");
});
it("should default to <tagName>", () => {
expect.assertions(1);
const node = new HtmlElement("my-element");
expect(node.annotatedName).toEqual("<my-element>");
});
});
it("rootNode()", () => {
const node = HtmlElement.rootNode(location);
expect(node.isRootElement()).toBeTruthy();
......@@ -385,6 +400,43 @@ describe("HtmlElement", () => {
});
});
describe("loadMeta()", () => {
let node: HtmlElement;
const original = {
inherit: "foo",
flow: true,
} as MetaElement;
beforeEach(() => {
node = new HtmlElement("my-element", null, null, original);
});
it("should overwrite copyable properties", () => {
expect.assertions(1);
node.loadMeta({ flow: false } as MetaElement);
expect(node.meta.flow).toEqual(false);
});
it("should not overwrite non-copyable properties", () => {
expect.assertions(1);
node.loadMeta({ inherit: "bar" } as MetaElement);
expect(node.meta.inherit).toEqual("foo");
});
it("should remove missing properties", () => {
expect.assertions(1);
node.loadMeta({} as MetaElement);
expect(node.meta.flow).toBeUndefined();
});
it("should handle when original meta is null", () => {
expect.assertions(1);
const node = new HtmlElement("my-element");
node.loadMeta({ flow: false } as MetaElement);
expect(node.meta.flow).toEqual(false);
});
});
describe("getElementsByTagName()", () => {
it("should find elements", () => {
const nodes = root.getElementsByTagName("li");
......
import { Location, sliceLocation } from "../context";
import { Token } from "../lexer";
import { MetaElement, MetaTable } from "../meta";
import { MetaCopyableProperty, MetaElement, MetaTable } from "../meta";
import { Attribute } from "./attribute";
import { DOMNode } from "./domnode";
import { DOMTokenList } from "./domtokenlist";
......@@ -19,12 +19,13 @@ export enum NodeClosed {
export class HtmlElement extends DOMNode {
public readonly tagName: string;
public readonly meta: MetaElement;
public readonly parent: HtmlElement;
public readonly voidElement: boolean;
public readonly depth: number;
public closed: NodeClosed;
protected readonly attr: { [key: string]: Attribute[] };
private metaElement: MetaElement;
private annotation: string | null;
public constructor(
tagName: string,
......@@ -39,10 +40,11 @@ export class HtmlElement extends DOMNode {
this.tagName = tagName;
this.parent = parent;
this.attr = {};
this.meta = meta;
this.metaElement = meta;
this.closed = closed;
this.voidElement = this.meta ? this.meta.void : false;
this.voidElement = meta ? meta.void : false;
this.depth = 0;
this.annotation = null;
if (parent) {
parent.childNodes.push(this);
......@@ -93,6 +95,19 @@ export class HtmlElement extends DOMNode {
);
}
/**
* Returns annotated name if set or defaults to `<tagName>`.
*
* E.g. `my-annotation` or `<div>`.
*/
public get annotatedName(): string {
if (this.annotation) {
return this.annotation;
} else {
return `<${this.tagName}>`;
}
}
/**
* Similar to childNodes but only elements.
*/
......@@ -128,6 +143,47 @@ export class HtmlElement extends DOMNode {
return (this.tagName && tagName === "*") || this.tagName === tagName;
}
/**
* Load new element metadata onto this element.
*
* Do note that semantics such as `void` cannot be changed (as the element has
* already been created). In addition the element will still "be" the same
* element, i.e. even if loading meta for a `<p>` tag upon a `<div>` tag it
* will still be a `<div>` as far as the rest of the validator is concerned.
*
* In fact only certain properties will be copied onto the element:
*
* - content categories (flow, phrasing, etc)
* - required attributes
* - attribute allowed values
* - permitted/required elements
*
* Properties *not* loaded:
*
* - inherit
* - deprecated
* - foreign
* - void
* - implicitClosed
* - scriptSupporting
* - deprecatedAttributes
*
* Changes to element metadata will only be visible after `element:ready` (and
* the subsequent `dom:ready` event).
*/
public loadMeta(meta: MetaElement): void {
if (!this.metaElement) {
this.metaElement = {} as MetaElement;
}
for (const key of MetaCopyableProperty) {
if (typeof meta[key] !== "undefined") {
this.metaElement[key] = meta[key];
} else {
delete this.metaElement[key];
}
}
}
/**
* Match this element against given selectors. Returns true if any selector
* matches.
......@@ -155,6 +211,17 @@ export class HtmlElement extends DOMNode {
return false;
}
public get meta(): MetaElement {
return this.metaElement;
}
/**
* Set annotation for this element.
*/
public setAnnotation(text: string): void {
this.annotation = text;
}
public setAttribute(
key: string,
value: string | DynamicValue,
......
......@@ -8,14 +8,18 @@ exports[`SchemaValidationError should pretty-print validation errors 1`] = `
> 3 | \\"flow\\": \\"spam\\"
| ^^^^^^ 👈🏽 type should be boolean
4 | }
5 | },TYPE should be array
5 | }
TYPE should be array
1 | {
2 | \\"foo\\": {
> 3 | \\"flow\\": \\"spam\\"
| ^^^^^^ 👈🏽 type should be array
4 | }
5 | },ANYOF should match some schema in anyOf
5 | }
ANYOF should match some schema in anyOf
1 | {
2 | \\"foo\\": {
......
import betterAjvErrors from "@sidvind/better-ajv-errors";
import Ajv from "ajv";
import betterAjvErrors from "better-ajv-errors";
import { UserError } from "../error/user-error";
function getSummary(schema: any, obj: any, errors: Ajv.ErrorObject[]): string {
const output = betterAjvErrors(schema, obj, errors, {
format: "js",
}) as any;
// istanbul ignore next: for safety only
return output.length > 0 ? output[0].error : "unknown validation error";
}
export class SchemaValidationError extends UserError {
public filename: string | null;
private obj: any;
......@@ -15,7 +23,9 @@ export class SchemaValidationError extends UserError {
schema: any,
errors: Ajv.ErrorObject[]
) {
super(message);
const summary = getSummary(schema, obj, errors);
super(`${message}: ${summary}`);
this.filename = filename;
this.obj = obj;
this.schema = schema;
......
......@@ -49,6 +49,10 @@ export interface MetaData {
requiredContent?: RequiredContent;
}
/**
* Properties listed here can be used to reverse search elements with the given
* property enabled. See [[MetaTable.getTagsWithProperty]].
*/
export type MetaLookupableProperty =
| "metadata"
| "flow"
......@@ -64,6 +68,29 @@ export type MetaLookupableProperty =
| "scriptSupporting"
| "form";
/**
* Properties listed here can be copied (loaded) onto another element using
* [[HtmlElement.loadMeta]].
*/
export const MetaCopyableProperty = [
"metadata",
"flow",
"sectioning",
"heading",
"phrasing",
"embedded",
"interactive",
"transparent",
"form",
"requiredAttributes",
"attributes",
"permittedContent",
"permittedDescendants",
"permittedOrder",
"requiredAncestors",
"requiredContent",
];
export interface MetaElement extends MetaData {
/* filled internally for reverse lookup */
tagName: string;
......
......@@ -3,6 +3,7 @@ export {
MetaData,
MetaElement,
MetaLookupableProperty,
MetaCopyableProperty,
PropertyExpression,
} from "./element";
export { Validator } from "./validator";
......@@ -13,6 +13,9 @@ jest.mock("ajv", () => {
public compile(): () => boolean {
return validate;
}
public addMetaSchema(): void {
/* do nothing */
}
}
return MockAjv;
});
......
import Ajv from "ajv";
import betterAjvErrors from "better-ajv-errors";
import deepmerge from "deepmerge";
import jsonMergePatch from "json-merge-patch";
import { HtmlElement } from "../dom";
import { SchemaValidationError, UserError } from "../error";
import { SchemaValidationPatch } from "../plugin";
import schema from "../schema/elements.json";
import {
ElementTable,
MetaData,
......@@ -42,7 +42,7 @@ export class MetaTable {
public constructor() {
this.elements = {};
this.schema = clone(require("../../elements/schema.json"));
this.schema = clone(schema);
}
public init(): void {
......@@ -77,16 +77,13 @@ export class MetaTable {
filename: string | null = null
): void {
const ajv = new Ajv({ jsonPointers: true });
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
const validator = ajv.compile(this.schema);
const valid = validator(obj);
if (!valid) {
const output = betterAjvErrors(this.schema, obj, validator.errors, {
format: "js",
}) as any;
const message = output[0].error;
throw new SchemaValidationError(
filename,
`Element metadata is not valid: ${message}`,
`Element metadata is not valid`,
obj,
this.schema,
validator.errors
......@@ -114,9 +111,12 @@ export class MetaTable {
this.loadFromObject(clone(json), filename);
}
/**
* Get [[MetaElement]] for the given tag or null if the element doesn't exist.
*
* @returns A shallow copy of metadata.
*/
public getMetaFor(tagName: string): MetaElement {
/* @TODO Only entries with dynamic properties has to be copied, static
* entries could be shared */
tagName = tagName.toLowerCase();
return this.elements[tagName]
? Object.assign({}, this.elements[tagName])
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parser should postprocess elements allow modifiy element metadata 1`] = `
Object {
"attributes": Object {
"contenteditable": Array [
"",
"true",
"false",
],
"dir": Array [
"ltr",
"rtl",
"auto",
],
"draggable": Array [
"true",
"false",
],
"hidden": Array [],
"tabindex": Array [
/-\\?\\\\d\\+/,
],
},
"deprecatedAttributes": Array [
"contextmenu",
],
"flow": true,
"permittedContent": Array [
"@flow",
"dt",
"dd",
],
"tagName": "i",
"void": false,
}
`;
exports[`parser should postprocess elements allow modifiy element metadata 2`] = `
Object {
"attributes": Object {
"contenteditable": Array [
"",
"true",
"false",
],
"dir": Array [
"ltr",
"rtl",
"auto",
],
"draggable": Array [
"true",
"false",
],
"hidden": Array [],
"tabindex": Array [
/-\\?\\\\d\\+/,
],
},
"deprecatedAttributes": Array [
"contextmenu",
],
"flow": true,
"permittedContent": Array [
"@phrasing",
],
"phrasing": true,
"tagName": "u",
"void": false,
}
`;
import { Config } from "../config";
import { Location, Source } from "../context";
import { Location, ProcessElementContext, Source } from "../context";
import { DOMTree, HtmlElement, TextNode } from "../dom";
import { EventCallback } from "../event";
import HtmlValidate from "../htmlvalidate";
......@@ -999,20 +999,55 @@ describe("parser", () => {
expect(events.shift()).toBeUndefined();
});
it("elements", () => {
const processElement = jest.fn();
const source: Source = {
data: "<input>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
parser.parseHtml(source);
expect(processElement).toHaveBeenCalledWith(expect.any(HtmlElement));
describe("elements", () => {
it("by calling hook", () => {
let context: any;
const processElement = jest.fn(function(this: any) {
context = this;
});
const source: Source = {
data: "<input>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
parser.parseHtml(source);
expect(processElement).toHaveBeenCalledWith(expect.any(HtmlElement));
expect(context).toEqual({
getMetaFor: expect.any(Function),
});
});
it("allow modifiy element metadata", () => {
expect.assertions(2);
function processElement(
this: ProcessElementContext,
node: HtmlElement
): void {
if (node.tagName === "i") {
node.loadMeta(this.getMetaFor("div"));
}
}
const source: Source = {
data: "<i></i><u></u>",
filename: "inline",
line: 1,
column: 1,
offset: 0,
hooks: {
processElement,
},
};
const doc = parser.parseHtml(source);
const i = doc.querySelector("i");
const u = doc.querySelector("u");
expect(i.meta).toMatchSnapshot();
expect(u.meta).toMatchSnapshot();
});
});
});
......
import { Config } from "../config";
import { Location, sliceLocation, Source } from "../context";
import { ProcessAttributeCallback } from "../context/source";
import {
ProcessAttributeCallback,
ProcessElementContext,
} from "../context/source";
import { DOMTree, HtmlElement, NodeClosed } from "../dom";
import {
AttributeEvent,
......@@ -200,6 +203,8 @@ export class Parser {
const close = !open || node.closed !== NodeClosed.Open;
const foreign = node.meta && node.meta.foreign;
/* if the previous tag to be implicitly closed by the current tag we close
* it and pop it from the stack before continuing processing this tag */
if (closeOptional) {
const active = this.dom.getActive();
active.closed = NodeClosed.ImplicitClosed;
......@@ -264,10 +269,7 @@ export class Parser {
location: Location
): void {
/* call processElement hook */
if (source.hooks && source.hooks.processElement) {
const processElement = source.hooks.processElement;
processElement(active);
}
this.processElement(active, source);
/* trigger event for the closing of the element (the </> tag)*/
this.trigger("tag:close", {
......@@ -286,6 +288,19 @@ export class Parser {
}
}
private processElement(node: HtmlElement, source: Source): void {
if (source.hooks && source.hooks.processElement) {
const processElement = source.hooks.processElement;
const metaTable = this.metaTable;
const context: ProcessElementContext = {
getMetaFor(tagName: string) {
return metaTable.getMetaFor(tagName);
},
};
processElement.call(context, node);
}
}
/**
* Discard tokens until the end tag for the foreign element is found.
*/
......@@ -371,7 +386,7 @@ export class Parser {
/* handle deprecated callbacks */
let iterator: Iterable<AttributeData>;
const legacy = processAttribute(attrData);
const legacy = processAttribute.call({}, attrData);
if (typeof (legacy as any)[Symbol.iterator] !== "function") {
/* AttributeData */
iterator = [attrData];
......
......@@ -52,7 +52,7 @@ class ElementPermittedContent extends Rule {
if (!Validator.validatePermitted(cur, rules)) {
this.report(
cur,
`Element <${cur.tagName}> is not permitted as content in <${parent.tagName}>`
`Element <${cur.tagName}> is not permitted as content in ${parent.annotatedName}`
);
return true;
}
......@@ -83,7 +83,7 @@ class ElementPermittedContent extends Rule {
) {
this.report(
node,
`Element <${node.tagName}> is not permitted as descendant of <${parent.tagName}>`
`Element <${node.tagName}> is not permitted as descendant of ${parent.annotatedName}`
);
return true;
}
......
......@@ -40,7 +40,7 @@ class ElementPermittedOccurrences extends Rule {
) {
this.report(
node,
`Element <${node.tagName}> can only appear once under <${parent.tagName}>`
`Element <${node.tagName}> can only appear once under ${parent.annotatedName}`
);
}
});
......
......@@ -36,7 +36,7 @@ class ElementRequiredAttributes extends Rule<Context> {
this.report(
node,
`<${node.tagName}> is missing required "${key}" attribute`,
`${node.annotatedName} is missing required "${key}" attribute`,
node.location,
context
);
......
......@@ -42,7 +42,7 @@ class ElementRequiredContent extends Rule<Context> {
};
this.report(
node,
`<${node.tagName}> element must have <${missing}> as content`,
`${node.annotatedName} element must have <${missing}> as content`,
null,
context
);
......
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$id": "https://html-validate.org/schemas/config.json",
"version": "1.0",
"type": "object",
"additionalProperties": false,
"properties": {
"root": {
"type": "boolean",
"title": "Mark as root configuration",
"description": "If this is set to true no further configurations will be searched.",
"default": false
},
"extends": {
"type": "array",
"items": {
"type": "string"
},
"title": "Configurations to extend",
"description": "Array of sharable or builtin configurations to extend."
},
"elements": {
"type": "array",
"items": {
"anyOf": [{ "type": "string" }, { "type": "object" }]
},
"title": "Element metadata to load",
"description": "Array of modules, plugins or files to load element metadata from. Use <rootDir> to refer to the folder with the package.json file.",
"example": [
"html-validate:recommended",
"plugin:recommended",
"module",