...
 
Commits (24)
# html-validate changelog
# [2.0.0](https://gitlab.com/html-validate/html-validate/compare/v1.16.0...v2.0.0) (2019-11-17)
### Features
- **config:** transformers must now operate on `Source` ([9c2112c](https://gitlab.com/html-validate/html-validate/commit/9c2112c8fb71275434b3d212df0953a4ea467db4))
- **config:** wrap transformer error message better ([9f833e9](https://gitlab.com/html-validate/html-validate/commit/9f833e9d4dcc17ad32cca43a546da5f62e52dfe2))
- **htmlvalidate:** string sources are now transformed too ([0645e37](https://gitlab.com/html-validate/html-validate/commit/0645e3760bd8d0f168a2c1faaa3e7097aa00b330))
- **plugin:** support exposing transformers from plugins ([1370565](https://gitlab.com/html-validate/html-validate/commit/13705651c1b00e9dbd5cc3914b317f964391d6a8))
- **transform:** add context object as `this` ([cb76cb3](https://gitlab.com/html-validate/html-validate/commit/cb76cb33664ca6e3ca37772aa14a4984faf55804))
- **transform:** add version number to API ([94a5663](https://gitlab.com/html-validate/html-validate/commit/94a5663440c904fb0ad80dcbbab60ad73c79f741))
- **transform:** adding test utils ([9e42590](https://gitlab.com/html-validate/html-validate/commit/9e42590a6112a095a0d9b01eb1af98189168f25e))
- **transform:** support chaining transformers ([4a6fd51](https://gitlab.com/html-validate/html-validate/commit/4a6fd51620621f228aa4897abded19ce1abc7d1e))
- **transform:** support returning iterators ([623b2f2](https://gitlab.com/html-validate/html-validate/commit/623b2f20efdce9ee4b3f39d1cf698d412116e79b))
### BREAKING CHANGES
- **config:** Previously transformers took a filename and had to read data of
the file itself. Transformers will now receive a `Source` instance with the data
preread.
# [1.16.0](https://gitlab.com/html-validate/html-validate/compare/v1.15.0...v1.16.0) (2019-11-09)
### Bug Fixes
......
......@@ -5,6 +5,47 @@
# Transformers
## API
Each transformer must implement the following API:
```typescript
import { Transformer, TransformContext } from "html-validate";
/* implementation */
function myTransform(this: TransformContext, source: Source): Iterable<Source> {
/* ... */
}
/* api version declaration */
myTransform.api = 1;
/* export */
module.exports = myTransform as Transfomer;
```
### `TransformContext`
```typescript
export interface TransformContext {
chain(source: Source, filename: string): Iterable<Source>;
}
```
#### `chain`
Chain transformations. Sometimes multiple transformers must be applied. For
instance, a Markdown file with JSX in a code-block.
```typescript
for (const source of myTransformation()) {
yield * this.chain(source, `${originalFilename}.foo`);
}
```
The above snippet will chain transformations using the current transformer
matching `*.foo` files, if it is configured.
## `TemplateExtractor`
Extracts templates from javascript sources.
......
......@@ -39,8 +39,9 @@ export interface Plugin {
*
* Each key should be the unprefixed name which a configuration later can
* access using `${plugin}:${key}`, e.g. if a plugin named "my-plugin" exposes
* a preset named "foobar" it can be accessed using `"extends":
* ["my-plugin:foobar"]`.
* a preset named "foobar" it can be accessed using:
*
* "extends": ["my-plugin:foobar"]
*/
configs: { [key: string]: ConfigData };
......@@ -49,6 +50,30 @@ export interface Plugin {
*/
rules: { [key: string]: RuleConstructor };
/**
* Transformer available in this plugin.
*
* Can be given either as a single unnamed transformer or an object with
* multiple named.
*
* Unnamed transformers use the plugin name similar to how a standalone
* transformer would work:
*
* "transform": {
* "^.*\\.foo$": "my-plugin"
* }
*
* For named transformers each key should be the unprefixed name which a
* configuration later can access using `${plugin}:${key}`, e.g. if a plugin
* named "my-plugin" exposes a transformer named "foobar" it can be accessed
* using:
*
* "transform": {
* "^.*\\.foo$": "my-plugin:foobar"
* }
*/
transformer: Transformer | Record<string, Transformer>;
/**
* Extend metadata validation schema.
*/
......@@ -145,6 +170,52 @@ This makes the rules accessable as usual when configuring in
}
```
## Transformer
Similar to standalone transformers plugins may also expose them. This can be
useful to combine transformations, rules and a default set of configuration
suitable for the filetype/framework.
```js
const MyTransformer = require("./transformers/my-transformer.js");
module.exports = {
transformer: MyTransformer,
};
```
Users may then extend the preset using the plugin name, e.g.:
```js
{
"transform": {
"^.*\\.foo$": "my-plugin"
}
}
```
If you need multiple transformers export an object with named transformers instead:
```js
const MyTransformer = require("./transformers/my-transformer.js");
module.exports = {
transformer: {
"my-transformer": MyTransformer,
},
};
```
Users may then extend the preset using `plugin:name`, e.g.:
```js
{
"transform": {
"^.*\\.foo$": "my-plugin:my-transformer"
}
}
```
## Extend metadata
Plugins can extend the available [element metadata](/usage/elements.html) by
......
This diff is collapsed.
{
"name": "html-validate",
"version": "1.16.0",
"version": "2.0.0",
"description": "html linter",
"keywords": [
"html",
......@@ -25,6 +25,17 @@
"bin": {
"html-validate": "bin/html-validate.js"
},
"files": [
"bin",
"build",
"elements",
"!*.snap",
"!*.spec.d.ts",
"!*.spec.js",
"!*.spec.ts",
"!__mocks__",
"!build/rules/**/*.d.ts"
],
"scripts": {
"build": "tsc",
"build:docs": "grunt docs",
......@@ -127,12 +138,12 @@
"@types/estree": "0.0.39",
"@types/glob": "7.1.1",
"@types/inquirer": "6.5.0",
"@types/jest": "24.0.22",
"@types/jest": "24.0.23",
"@types/json-merge-patch": "0.0.4",
"@types/minimist": "1.2.0",
"@types/node": "11.15.2",
"@typescript-eslint/eslint-plugin": "2.6.1",
"@typescript-eslint/parser": "2.6.1",
"@typescript-eslint/eslint-plugin": "2.7.0",
"@typescript-eslint/parser": "2.7.0",
"autoprefixer": "9.7.1",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
......@@ -144,7 +155,7 @@
"eslint-config-sidvind": "1.3.2",
"eslint-plugin-array-func": "3.1.3",
"eslint-plugin-import": "2.18.2",
"eslint-plugin-jest": "23.0.3",
"eslint-plugin-jest": "23.0.4",
"eslint-plugin-node": "10.0.0",
"eslint-plugin-prettier": "3.1.1",
"eslint-plugin-security": "1.4.0",
......@@ -163,11 +174,11 @@
"jest-diff": "24.9.0",
"jest-junit": "9.0.0",
"jquery": "3.4.1",
"lint-staged": "9.4.2",
"lint-staged": "9.4.3",
"load-grunt-tasks": "5.1.0",
"minimatch": "3.0.4",
"prettier": "1.18.2",
"sass": "1.23.3",
"prettier": "1.19.1",
"sass": "1.23.6",
"semantic-release": "15.13.30",
"serve-static": "1.14.1",
"strip-ansi": "5.2.0",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`config transform() 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 1`] = `"When transforming \\"/path/to/test.foo\\": Failed to frobnicate a baz"`;
exports[`config transform() should throw sane error when transformer fails to load 1`] = `"Failed to load transformer \\"missing-transformer\\""`;
exports[`config transformSource() should throw sane error when transformer fails to load 1`] = `"Failed to load transformer \\"missing-transformer\\""`;
......@@ -190,13 +190,10 @@ describe("ConfigLoader", () => {
const data = src.get();
data.rules = Object.keys(data.rules)
.filter(key => whitelisted.includes(key))
.reduce(
(dst, key) => {
dst[key] = data.rules[key];
return dst;
},
{} as RuleConfig
);
.reduce((dst, key) => {
dst[key] = data.rules[key];
return dst;
}, {} as RuleConfig);
return data;
}
......
import fs from "fs";
import path from "path";
import { Source } from "../context";
import { UserError } from "../error/user-error";
import { Config } from "./config";
import { Severity } from "./severity";
......@@ -17,16 +18,6 @@ jest.mock(
{ virtual: true }
);
/* mock transformers */
jest.mock(
"mock-transformer-error",
() =>
function mockTranformerError() {
throw new Error("Failed to frobnicate a baz");
},
{ virtual: true }
);
/* mock plugin with config presets */
jest.mock(
"mock-plugin-presets",
......@@ -326,71 +317,119 @@ describe("config", () => {
});
});
describe("transform()", () => {
describe("transformSource()", () => {
let source: Source;
beforeEach(() => {
source = {
filename: "/path/to/test.foo",
data: "original data",
line: 2,
column: 3,
};
});
it("should match filename against transformer", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "../transform/mock",
"^.*\\.foo$": "mock-transform",
},
});
config.init();
expect(config.transform("/path/to/test.foo")).toEqual([
{
data: "mocked source",
filename: "/path/to/test.foo",
line: 1,
column: 1,
originalData: "mocked original source",
expect(config.transformSource(source)).toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "transformed source (was: original data)",
"filename": "/path/to/test.foo",
"line": 1,
"originalData": "original data",
},
]
`);
});
it("should throw error if transformer uses obsolete API", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "mock-transform-obsolete",
},
]);
});
expect(() => config.init()).toThrow(
/Failed to load transformer "mock-transform-obsolete": Transformer uses API version 0 but only version \d+ is supported/
);
});
it("should replace <rootDir>", () => {
it("should return original source if no transformer is found", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "<rootDir>/src/transform/mock",
"^.*\\.bar$": "mock-transform",
},
});
config.init();
expect(config.transform("/path/to/test.foo")).toEqual([
{
data: "mocked source",
filename: "/path/to/test.foo",
line: 1,
column: 1,
originalData: "mocked original source",
expect(config.transformSource(source)).toMatchInlineSnapshot(`
Array [
Object {
"column": 3,
"data": "original data",
"filename": "/path/to/test.foo",
"line": 2,
},
]
`);
});
it("should support chaining transformer", () => {
const config = Config.fromObject({
transform: {
"^.*\\.bar$": "mock-transform-chain-foo",
"^.*\\.foo$": "mock-transform",
},
]);
});
config.init();
source.filename = "/path/to/test.bar";
expect(config.transformSource(source)).toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "transformed source (was: data from mock-transform-chain-foo (was: original data))",
"filename": "/path/to/test.bar",
"line": 1,
"originalData": "original data",
},
]
`);
});
it("should default to reading full file", () => {
it("should replace <rootDir>", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "../transform/mock",
"^.*\\.foo$": "<rootDir>/src/transform/__mocks__/mock-transform",
},
});
config.init();
expect(config.transform("test-files/parser/simple.html")).toEqual([
{
data: "<p>Lorem ipsum</p>\n",
filename: "test-files/parser/simple.html",
line: 1,
column: 1,
originalData: "<p>Lorem ipsum</p>\n",
},
]);
expect(config.transformSource(source)).toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "transformed source (was: original data)",
"filename": "/path/to/test.foo",
"line": 1,
"originalData": "original data",
},
]
`);
});
it("should throw sane error when transformer fails", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$":
"mock-transformer-error" /* mocked transformer, see top of file */,
"^.*\\.foo$": "mock-transform-error",
},
});
config.init();
expect(() =>
config.transform("/path/to/test.foo")
config.transformSource(source)
).toThrowErrorMatchingSnapshot();
});
......@@ -405,6 +444,31 @@ describe("config", () => {
});
});
describe("transformFilename()", () => {
it("should default to reading full file", () => {
const config = Config.fromObject({
transform: {
"^.*\\.foo$": "mock-transform",
},
});
config.init();
expect(config.transformFilename("test-files/parser/simple.html"))
.toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "<p>Lorem ipsum</p>
",
"filename": "test-files/parser/simple.html",
"line": 1,
"originalData": "<p>Lorem ipsum</p>
",
},
]
`);
});
});
describe("init()", () => {
it("should handle unset fields", () => {
const config = Config.fromObject({
......
......@@ -6,14 +6,15 @@ import { NestedError } from "../error";
import { MetaTable } from "../meta";
import { MetaDataTable } from "../meta/element";
import { Plugin } from "../plugin";
import { TransformContext, Transformer, TRANSFORMER_API } from "../transform";
import { ConfigData, TransformMap } from "./config-data";
import defaultConfig from "./default";
import { ConfigError } from "./error";
import { parseSeverity, Severity } from "./severity";
interface Transformer {
interface TransformerEntry {
pattern: RegExp;
fn: (filename: string) => Source[];
fn: Transformer;
}
const recommended = require("./recommended");
......@@ -76,7 +77,7 @@ export class Config {
private configurations: Map<string, ConfigData>;
protected metaTable: MetaTable;
protected plugins: Plugin[];
protected transformers: Transformer[];
protected transformers: TransformerEntry[];
protected rootDir: string;
/**
......@@ -321,59 +322,138 @@ export class Config {
}
/**
* Transform a source file.
* Transform a source.
*
* @param filename - Filename to transform (according to configured
* transformations)
* @return A list of extracted sources ready for validation.
* When transforming zero or more new sources will be generated.
*
* @param source - Current source to transform.
* @param filename - If set it is the filename used to match
* transformer. Default is to use filename from source.
* @return A list of transformed sources ready for validation.
*/
public transform(filename: string): Source[] {
const transformer = this.findTransformer(filename);
public transformSource(source: Source, filename?: string): Source[] {
const transformer = this.findTransformer(filename || source.filename);
const context: TransformContext = {
chain: (source: Source, filename: string) => {
return this.transformSource(source, filename);
},
};
if (transformer) {
try {
return transformer.fn(filename);
return Array.from(transformer.fn.call(context, source));
} catch (err) {
throw new NestedError(
`When transforming "${filename}": ${err.message}`,
`When transforming "${source.filename}": ${err.message}`,
err
);
}
} else {
const data = fs.readFileSync(filename, { encoding: "utf8" });
return [
{
data,
filename,
line: 1,
column: 1,
originalData: data,
},
];
return [source];
}
}
private findTransformer(filename: string): Transformer | null {
return this.transformers.find((entry: Transformer) =>
/**
* Wrapper around [[transformSource]] which reads a file before passing it
* as-is to transformSource.
*
* @param source - Filename to transform (according to configured
* transformations)
* @return A list of transformed sources ready for validation.
*/
public transformFilename(filename: string): Source[] {
const data = fs.readFileSync(filename, { encoding: "utf8" });
const source: Source = {
data,
filename,
line: 1,
column: 1,
originalData: data,
};
return this.transformSource(source);
}
private findTransformer(filename: string): TransformerEntry | null {
return this.transformers.find((entry: TransformerEntry) =>
entry.pattern.test(filename)
);
}
private precompileTransformers(transform: TransformMap): Transformer[] {
return Object.entries(transform).map(([pattern, module]) => {
private precompileTransformers(transform: TransformMap): TransformerEntry[] {
return Object.entries(transform).map(([pattern, name]) => {
try {
const fn = this.getTransformFunction(name);
const version = (fn as any).api || 0;
/* check if transformer version is supported */
if (version !== TRANSFORMER_API.VERSION) {
throw new ConfigError(
`Transformer uses API version ${version} but only version ${TRANSFORMER_API.VERSION} is supported`
);
}
return {
// eslint-disable-next-line security/detect-non-literal-regexp
pattern: new RegExp(pattern),
// eslint-disable-next-line security/detect-non-literal-require
fn: require(module.replace("<rootDir>", this.rootDir)),
fn,
};
} catch (err) {
throw new ConfigError(`Failed to load transformer "${module}"`, err);
if (err instanceof ConfigError) {
throw new ConfigError(
`Failed to load transformer "${name}": ${err.message}`,
err
);
} else {
throw new ConfigError(`Failed to load transformer "${name}"`, err);
}
}
});
}
/**
* Get transformation function requested by configuration.
*
* Searches:
*
* - Named transformers from plugins.
* - Unnamed transformer from plugin.
* - Standalone modules (local or node_modules)
*/
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 (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];
}
/* try to match an unnamed transformer from plugin */
const plugin = this.plugins.find(cur => (cur.name = name));
if (plugin) {
if (typeof plugin.transformer !== "function") {
throw new ConfigError(
`Transformer "${name}" refers to unnamed transformer but plugin exposes only named.`
);
}
return plugin.transformer;
}
/* assume transformer refers to a regular module */
// eslint-disable-next-line security/detect-non-literal-require
return require(name.replace("<rootDir>", this.rootDir));
}
protected findRootDir(): string {
if (rootDirCache !== null) {
return rootDirCache;
......
......@@ -22,7 +22,7 @@ jest.mock("./parser");
function mockConfig(): Config {
const config = Config.empty();
config.init();
config.transform = jest.fn((filename: string) => [
config.transformFilename = jest.fn((filename: string) => [
{
column: 1,
data: `source from ${filename}`,
......@@ -282,7 +282,7 @@ describe("HtmlValidate", () => {
const filename = "foo.html";
const config = Config.empty();
config.init();
config.transform = jest.fn((filename: string) => [
config.transformFilename = jest.fn((filename: string) => [
{
column: 1,
data: `first markup`,
......
......@@ -55,13 +55,14 @@ class HtmlValidate {
/**
* Parse and validate HTML from [[Source]].
*
* @param source - Source to parse.
* @param input - Source to parse.
* @returns Report output.
*/
public validateSource(source: Source): Report {
const config = this.getConfigFor(source.filename);
public validateSource(input: Source): Report {
const config = this.getConfigFor(input.filename);
const source = config.transformSource(input);
const engine = new Engine(config, Parser);
return engine.lint([source]);
return engine.lint(source);
}
/**
......@@ -72,7 +73,7 @@ class HtmlValidate {
*/
public validateFile(filename: string): Report {
const config = this.getConfigFor(filename);
const source = config.transform(filename);
const source = config.transformFilename(filename);
const engine = new Engine(config, Parser);
return engine.lint(source);
}
......@@ -100,7 +101,7 @@ class HtmlValidate {
*/
public dumpTokens(filename: string): TokenDump[] {
const config = this.getConfigFor(filename);
const source = config.transform(filename);
const source = config.transformFilename(filename);
const engine = new Engine(config, Parser);
return engine.dumpTokens(source);
}
......@@ -115,7 +116,7 @@ class HtmlValidate {
*/
public dumpEvents(filename: string): EventDump[] {
const config = this.getConfigFor(filename);
const source = config.transform(filename);
const source = config.transformFilename(filename);
const engine = new Engine(config, Parser);
return engine.dumpEvents(source);
}
......@@ -130,7 +131,7 @@ class HtmlValidate {
*/
public dumpTree(filename: string): string[] {
const config = this.getConfigFor(filename);
const source = config.transform(filename);
const source = config.transformFilename(filename);
const engine = new Engine(config, Parser);
return engine.dumpTree(source);
}
......@@ -145,19 +146,14 @@ class HtmlValidate {
*/
public dumpSource(filename: string): string[] {
const config = this.getConfigFor(filename);
const sources = config.transform(filename);
return sources.reduce(
(result: string[], source: Source) => {
result.push(
`Source ${source.filename}@${source.line}:${source.column}`
);
result.push("---");
result = result.concat(source.data.split("\n"));
result.push("---");
return result;
},
[] as string[]
);
const sources = config.transformFilename(filename);
return sources.reduce((result: string[], source: Source) => {
result.push(`Source ${source.filename}@${source.line}:${source.column}`);
result.push("---");
result = result.concat(source.data.split("\n"));
result.push("---");
return result;
}, [] as string[]);
}
/**
......
......@@ -4,6 +4,7 @@ import { Engine } from "../engine";
import { EventHandler } from "../event";
import { Parser } from "../parser";
import { Rule } from "../rule";
import { Transformer, TRANSFORMER_API } from "../transform";
import { Plugin } from "./plugin";
let mockPlugin: Plugin;
......@@ -222,4 +223,136 @@ describe("Plugin", () => {
expect(setup).toHaveBeenCalledWith();
});
});
describe("transform", () => {
it("should support exposing unnamed transform", () => {
expect.assertions(1);
function transform(source: Source): Source[] {
return [
{
data: "transformed from unnamed transformer",
filename: source.filename,
line: source.line,
column: source.column,
originalData: source.data,
},
];
}
transform.api = TRANSFORMER_API.VERSION;
mockPlugin.transformer = transform as Transformer;
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin",
},
});
config.init();
const sources = config.transformSource({
data: "original data",
filename: "/path/to/mock.filename",
line: 2,
column: 3,
});
expect(sources).toMatchInlineSnapshot(`
Array [
Object {
"column": 3,
"data": "transformed from unnamed transformer",
"filename": "/path/to/mock.filename",
"line": 2,
"originalData": "original data",
},
]
`);
});
it("should support exposing named transform", () => {
expect.assertions(1);
function transform(source: Source): Source[] {
return [
{
data: "transformed from named transformer",
filename: source.filename,
line: source.line,
column: source.column,
originalData: source.data,
},
];
}
transform.api = TRANSFORMER_API.VERSION;
mockPlugin.transformer = {
foobar: transform as Transformer,
};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin:foobar",
},
});
config.init();
const sources = config.transformSource({
data: "original data",
filename: "/path/to/mock.filename",
line: 2,
column: 3,
});
expect(sources).toMatchInlineSnapshot(`
Array [
Object {
"column": 3,
"data": "transformed from named transformer",
"filename": "/path/to/mock.filename",
"line": 2,
"originalData": "original data",
},
]
`);
});
it("should throw error when named transform is missing", () => {
expect.assertions(1);
mockPlugin.transformer = {};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin:foobar",
},
});
expect(() => config.init()).toThrow(
'Failed to load transformer "mock-plugin:foobar": Plugin "mock-plugin" does not expose a transformer named "foobar".'
);
});
it("should throw error when referencing named transformer without name", () => {
expect.assertions(1);
mockPlugin.transformer = {
foobar: null,
};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin",
},
});
expect(() => config.init()).toThrow(
'Failed to load transformer "mock-plugin": Transformer "mock-plugin" refers to unnamed transformer but plugin exposes only named.'
);
});
it("should throw error when referencing unnamed transformer with name", () => {
expect.assertions(1);
mockPlugin.transformer = function transform(): Source[] {
return [];
};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin:foobar",
},
});
expect(() => config.init()).toThrow(
'Failed to load transformer "mock-plugin:foobar": Transformer "mock-plugin:foobar" refers to named transformer but plugin exposes only unnamed, use "mock-plugin" instead.'
);
});
});
});
......@@ -2,6 +2,7 @@ import { ConfigData } from "../config";
import { Source } from "../context";
import { EventHandler } from "../event";
import { RuleConstructor } from "../rule";
import { Transformer } from "../transform";
export interface SchemaValidationPatch {
properties?: object;
......@@ -39,8 +40,9 @@ export interface Plugin {
*
* Each key should be the unprefixed name which a configuration later can
* access using `${plugin}:${key}`, e.g. if a plugin named "my-plugin" exposes
* a preset named "foobar" it can be accessed using `"extends":
* ["my-plugin:foobar"]`.
* a preset named "foobar" it can be accessed using:
*
* "extends": ["my-plugin:foobar"]
*/
configs: { [key: string]: ConfigData };
......@@ -49,6 +51,30 @@ export interface Plugin {
*/
rules: { [key: string]: RuleConstructor };
/**
* Transformer available in this plugin.
*
* Can be given either as a single unnamed transformer or an object with
* multiple named.
*
* Unnamed transformers use the plugin name similar to how a standalone
* transformer would work:
*
* "transform": {
* "^.*\\.foo$": "my-plugin"
* }
*
* For named transformers each key should be the unprefixed name which a
* configuration later can access using `${plugin}:${key}`, e.g. if a plugin
* named "my-plugin" exposes a transformer named "foobar" it can be accessed
* using:
*
* "transform": {
* "^.*\\.foo$": "my-plugin:foobar"
* }
*/
transformer: Transformer | Record<string, Transformer>;
/**
* Extend metadata validation schema.
*/
......
import { readFileSync } from "fs";
import glob from "glob";
import { Source } from "./context";
import { DynamicValue } from "./dom";
import HtmlValidate from "./htmlvalidate";
import { AttributeData } from "./parser";
import { Transformer, TRANSFORMER_API } from "./transform";
jest.mock(
"mock-transformer",
() => {
return function transformer(filename: string) {
const data = readFileSync(filename, { encoding: "utf-8" });
const source: Source = {
data,
filename,
line: 1,
column: 1,
hooks: {
*processAttribute(attr: AttributeData) {
yield attr;
if (attr.key.startsWith("dynamic-")) {
yield {
key: attr.key.replace("dynamic-", ""),
value: new DynamicValue(attr.value as string),
quote: attr.quote,
originalAttribute: attr.key,
};
}
},
function transformer(source: Source): Iterable<Source> {
source.hooks = {
*processAttribute(attr: AttributeData) {
yield attr;
if (attr.key.startsWith("dynamic-")) {
yield {
key: attr.key.replace("dynamic-", ""),
value: new DynamicValue(attr.value as string),
quote: attr.quote,
originalAttribute: attr.key,
};
}
},
};
return [source];
};
}
transformer.api = TRANSFORMER_API.VERSION;
return transformer as Transformer;
},
{ virtual: true }
);
......
......@@ -8,7 +8,7 @@ export { DynamicValue, HtmlElement } from "./dom";
export { Rule } from "./rule";
export { Source, Location } from "./context";
export { Reporter, Message, Result } from "./reporter";
export { TemplateExtractor } from "./transform/template";
export { Transformer, TemplateExtractor } from "./transform";
const pkg = require("../package.json");
export const version = pkg.version;
import { Source } from "../../context";
import { Transformer, TransformContext, TRANSFORMER_API } from "..";
/**
* Mock transformer chaining to a .foo file transformer.
*/
function* mockTransformChainFoo(
this: TransformContext,
source: Source
): Iterable<Source> {
yield* this.chain(
{
data: `data from mock-transform-chain-foo (was: ${source.data})`,
filename: source.filename,
line: 1,
column: 1,
originalData: source.originalData || source.data,
},
`${source.filename}.foo`
);
}
/* mocks are always written against current version */
mockTransformChainFoo.api = TRANSFORMER_API.VERSION;
module.exports = mockTransformChainFoo as Transformer;
import { Source } from "../../context";
import { Transformer, TRANSFORMER_API } from "..";
/**
* Mock transformer always failing with an exception
*/
function mockTransformError(): Iterable<Source> {
throw new Error("Failed to frobnicate a baz");
}
mockTransformError.api = TRANSFORMER_API.VERSION;
module.exports = mockTransformError as Transformer;
import { Source } from "../../context";
import { Transformer } from "..";
/**
* Transformer returning a single mocked source.
*/
function mockTransform(): Iterable<Source> {
return [];
}
mockTransform.api = 0;
module.exports = mockTransform as Transformer;
import { Source } from "../../context";
import { Transformer, TRANSFORMER_API } from "..";
/**
* Transformer returning a single mocked source.
*/
function mockTransform(source: Source): Iterable<Source> {
return [
{
data: `transformed source (was: ${source.data})`,
filename: source.filename,
line: 1,
column: 1,
originalData: source.originalData || source.data,
},
];
}
/* mocks are always written against current version */
mockTransform.api = TRANSFORMER_API.VERSION;
module.exports = mockTransform as Transformer;
import { Source } from "../context";
export interface TransformContext {
/**
* Chain transformations.
*
* Sometimes multiple transformers must be applied. For instance, a Markdown
* file with JSX in a code-block.
*
* @param source - Source to chain transformations on.
* @param filename - Filename to use to match next transformer (unrelated to
* filename set in source)
*/
chain(source: Source, filename: string): Iterable<Source>;
}
import { Source } from "../context";
import { TransformContext } from "./context";
export { TransformContext } from "./context";
export { TemplateExtractor } from "./template";
export type Transformer = (
this: TransformContext,
source: Source
) => Iterable<Source>;
export enum TRANSFORMER_API {
VERSION = 1,
}
/**
* Transformer returning a single mocked source.
*/
module.exports = function mockTransform(filename: string) {
return [
{
data: "mocked source",
filename,
line: 1,
column: 1,
originalData: "mocked original source",
},
];
};
import fs from "fs";
import { TransformContext } from ".";
import { Source } from "../context";
import { transformFile, transformSource, transformString } from "./test-utils";
jest.mock("fs");
it("transformFile() should read file and apply transformer", () => {
const transformer = jest.fn((source: Source) => [source]);
const readFileSync = jest
.spyOn(fs, "readFileSync")
.mockImplementation(() => "mocked file data");
const result = transformFile(transformer, "foo.html");
expect(readFileSync).toHaveBeenCalledWith("foo.html", "utf-8");
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "mocked file data",
"filename": "foo.html",
"line": 1,
},
]
`);
});
it("transformString() should apply transformer", () => {
const transformer = jest.fn((source: Source) => [source]);
const result = transformString(transformer, "inline data");
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"column": 1,
"data": "inline data",
"filename": "inline",
"line": 1,
},
]
`);
});
it("transformSource() should apply transformer", () => {
const source: Source = {
filename: "bar.html",
line: 1,
column: 2,
data: "source data",
};
const transformer = jest.fn((source: Source) => [source]);
const result = transformSource(transformer, source);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"column": 2,
"data": "source data",
"filename": "bar.html",
"line": 1,
},
]
`);
});
it("transformSource() should support chaining", () => {
const source: Source = {
filename: "bar.html",
line: 1,
column: 2,
data: "source data",
};
const transformer = jest.fn(function(this: TransformContext, source: Source) {
return this.chain(source, "chained.html");
});
const result = transformSource(transformer, source);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"column": 2,
"data": "source data",
"filename": "bar.html",
"line": 1,
},
]
`);
});
it("transformSource() should support custom chaining", () => {
const source: Source = {
filename: "bar.html",
line: 1,
column: 2,
data: "source data",
};
const chain = jest.fn((source: Source) => [source]);
const transformer = jest.fn(function(this: TransformContext, source: Source) {
return this.chain(source, "chained.html");
});
const result = transformSource(transformer, source, chain);
expect(chain).toHaveBeenCalledWith(source, "chained.html");
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"column": 2,
"data": "source data",
"filename": "bar.html",
"line": 1,
},
]
`);
});
import fs from "fs";
import { TransformContext, Transformer } from ".";
import { Source } from "../context";
/**
* Helper function to call a transformer function in test-cases.
*
* @param fn - Transformer function to call.
* @param filename - Filename to read data from. Must be readable.
* @param chain - If set this function is called when chaining transformers. Default is pass-thru.
*/
export function transformFile(
fn: Transformer,
filename: string,
chain?: (source: Source, filename: string) => Iterable<Source>
): Source[] {
const data = fs.readFileSync(filename, "utf-8");
const source: Source = {
filename,
line: 1,
column: 1,
data,
};
return transformSource(fn, source, chain);
}
/**
* Helper function to call a transformer function in test-cases.
*
* @param fn - Transformer function to call.
* @param data - String to transform.
* @param chain - If set this function is called when chaining transformers. Default is pass-thru.
*/
export function transformString(
fn: Transformer,
data: string,
chain?: (source: Source, filename: string) => Iterable<Source>
): Source[] {
const source: Source = {
filename: "inline",
line: 1,
column: 1,
data,
};
return transformSource(fn, source, chain);
}
/**
* Helper function to call a transformer function in test-cases.
*
* @param fn - Transformer function to call.
* @param data - Source to transform.
* @param chain - If set this function is called when chaining transformers. Default is pass-thru.
*/
export function transformSource(
fn: Transformer,
source: Source,
chain?: (source: Source, filename: string) => Iterable<Source>
): Source[] {
const defaultChain = (source: Source): Iterable<Source> => [source];
const context: TransformContext = {
chain: chain || defaultChain,
};
return Array.from(fn.call(context, source));
}
......@@ -12,6 +12,7 @@
"member-access": true,
"member-ordering": false,
"no-console": false,
"no-empty-interface": false,
"no-shadowed-variable": false,
"no-var-requires": false,
"object-literal-sort-keys": false,
......