Commit 13705651 authored by David Sveningsson's avatar David Sveningsson

feat(plugin): support exposing transformers from plugins

parent 623b2f20
......@@ -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
......
......@@ -379,21 +379,64 @@ export class Config {
}
private precompileTransformers(transform: TransformMap): TransformerEntry[] {
return Object.entries(transform).map(([pattern, module]) => {
return Object.entries(transform).map(([pattern, name]) => {
try {
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: this.getTransformFunction(name),
};
} catch (err) {
throw new ConfigError(`Failed to load transformer "${module}"`, err);
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;
......
import { Config } from "../config";
import { Source } from "../context";
import { Engine } from "../engine";
import { NestedError } from "../error";
import { EventHandler } from "../event";
import { Parser } from "../parser";
import { Rule } from "../rule";
import { Transformer } from "../transform";
import { Plugin } from "./plugin";
let mockPlugin: Plugin;
......@@ -222,4 +224,165 @@ describe("Plugin", () => {
expect(setup).toHaveBeenCalledWith();
});
});
describe("transform", () => {
it("should support exposing unnamed transform", () => {
expect.assertions(1);
mockPlugin.transformer = function transform(source: Source): Source[] {
return [
{
data: "transformed from unnamed transformer",
filename: source.filename,
line: source.line,
column: source.column,
originalData: source.data,
},
];
} 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);
mockPlugin.transformer = {
foobar: function transform(source: Source): Source[] {
return [
{
data: "transformed from named transformer",
filename: source.filename,
line: source.line,
column: source.column,
originalData: source.data,
},
];
} 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(3);
mockPlugin.transformer = {};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin:foobar",
},
});
try {
config.init();
} catch (err) {
/* need to test NestedError to ensure the error messsage is sane */
/* eslint-disable jest/no-try-expect */
expect(err).toBeInstanceOf(NestedError);
expect(err.message).toContain(
'Failed to load transformer "mock-plugin:foobar"'
);
expect(err.stack).toContain(
'Plugin "mock-plugin" does not expose a transformer named "foobar".'
);
/* eslint-enable jest/no-try-expect */
}
});
it("should throw error when referencing named transformer without name", () => {
expect.assertions(3);
mockPlugin.transformer = {
foobar: null,
};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin",
},
});
try {
config.init();
} catch (err) {
/* need to test NestedError to ensure the error messsage is sane */
/* eslint-disable jest/no-try-expect */
expect(err).toBeInstanceOf(NestedError);
expect(err.message).toContain(
'Failed to load transformer "mock-plugin"'
);
expect(err.stack).toContain(
'Transformer "mock-plugin" refers to unnamed transformer but plugin exposes only named.'
);
/* eslint-enable jest/no-try-expect */
}
});
it("should throw error when referencing unnamed transformer with name", () => {
expect.assertions(3);
mockPlugin.transformer = function transform(): Source[] {
return [];
};
config = Config.fromObject({
plugins: ["mock-plugin"],
transform: {
".*": "mock-plugin:foobar",
},
});
try {
config.init();
} catch (err) {
/* need to test NestedError to ensure the error messsage is sane */
/* eslint-disable jest/no-try-expect */
expect(err).toBeInstanceOf(NestedError);
expect(err.message).toContain(
'Failed to load transformer "mock-plugin:foobar"'
);
expect(err.stack).toContain(
'Transformer "mock-plugin:foobar" refers to named transformer but plugin exposes only unnamed, use "mock-plugin" instead.'
);
/* eslint-enable jest/no-try-expect */
}
});
});
});
......@@ -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.
*/
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment