Skip to content

Commit 802aab1

Browse files
committed
feat: Adds support for plugins (conventional-changelog#228)
1 parent 9e2cf60 commit 802aab1

File tree

6 files changed

+250
-5
lines changed

6 files changed

+250
-5
lines changed

@commitlint/cli/src/cli.js

+10-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,16 @@ async function main(options) {
139139
const loadOpts = {cwd: flags.cwd, file: flags.config};
140140
const loaded = await load(getSeed(flags), loadOpts);
141141
const parserOpts = selectParserOpts(loaded.parserPreset);
142-
const opts = parserOpts ? {parserOpts} : {parserOpts: {}};
142+
const opts = {
143+
parserOpts: {},
144+
plugins: {}
145+
};
146+
if (parserOpts) {
147+
opts.parserOpts = parserOpts;
148+
}
149+
if (loaded.plugins) {
150+
opts.plugins = loaded.plugins;
151+
}
143152
const format = loadFormatter(loaded, flags);
144153

145154
// Strip comments if reading from `.git/COMMIT_EDIT_MSG`

@commitlint/lint/src/index.js

+13-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import util from 'util';
22
import isIgnored from '@commitlint/is-ignored';
33
import parse from '@commitlint/parse';
44
import implementations from '@commitlint/rules';
5-
import {toPairs} from 'lodash';
5+
import {toPairs, values} from 'lodash';
66

77
const buildCommitMesage = ({header, body, footer}) => {
88
let message = header;
@@ -41,6 +41,17 @@ export default async (message, rules = {}, opts = {}) => {
4141
);
4242
}
4343

44+
const mergedImplementations = {...implementations};
45+
if (opts.plugins) {
46+
values(opts.plugins).forEach(plugin => {
47+
if (plugin.rules) {
48+
Object.keys(plugin.rules).forEach(ruleKey => {
49+
mergedImplementations[ruleKey] = plugin.rules[ruleKey];
50+
});
51+
}
52+
});
53+
}
54+
4455
const invalid = toPairs(rules)
4556
.map(([name, config]) => {
4657
if (!Array.isArray(config)) {
@@ -120,7 +131,7 @@ export default async (message, rules = {}, opts = {}) => {
120131
return null;
121132
}
122133

123-
const rule = implementations[name];
134+
const rule = mergedImplementations[name];
124135

125136
const [valid, message] = rule(parsed, when, value);
126137

@commitlint/load/src/index.js

+11-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import resolveExtends from '@commitlint/resolve-extends';
44
import cosmiconfig from 'cosmiconfig';
55
import {toPairs, merge, mergeWith, pick} from 'lodash';
66
import resolveFrom from 'resolve-from';
7+
import loadPlugin from './utils/loadPlugin';
78

89
const w = (a, b) => (Array.isArray(b) ? b : undefined);
910
const valid = input =>
@@ -16,7 +17,7 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => {
1617
// Merge passed config with file based options
1718
const config = valid(merge(loaded.config, seed));
1819
const opts = merge(
19-
{extends: [], rules: {}, formatter: '@commitlint/format'},
20+
{extends: [], plugins: [], rules: {}, formatter: '@commitlint/format'},
2021
pick(config, 'extends')
2122
);
2223

@@ -35,7 +36,8 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => {
3536
const extended = resolveExtends(opts, {
3637
prefix: 'commitlint-config',
3738
cwd: base,
38-
parserPreset: config.parserPreset
39+
parserPreset: config.parserPreset,
40+
plugins: {}
3941
});
4042

4143
const preset = valid(mergeWith(extended, config, w));
@@ -55,6 +57,13 @@ export default async (seed = {}, options = {cwd: process.cwd()}) => {
5557
resolveFrom.silent(base, config.formatter) || config.formatter;
5658
}
5759

60+
// resolve plugins
61+
if (config.plugins && config.plugins.length) {
62+
config.plugins.forEach(pluginKey => {
63+
loadPlugin(preset.plugins, pluginKey, process.env.DEBUG === 'true');
64+
});
65+
}
66+
5867
// Execute rule config functions if needed
5968
const executed = await Promise.all(
6069
['rules']
+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import chalk from 'chalk';
2+
import {normalizePackageName, getShorthandName} from './pluginNaming';
3+
4+
export default function loadPlugin(plugins, pluginName, debug = false) {
5+
const longName = normalizePackageName(pluginName);
6+
const shortName = getShorthandName(longName);
7+
let plugin = null;
8+
9+
if (pluginName.match(/\s+/u)) {
10+
const whitespaceError = new Error(
11+
`Whitespace found in plugin name '${pluginName}'`
12+
);
13+
14+
whitespaceError.messageTemplate = 'whitespace-found';
15+
whitespaceError.messageData = {
16+
pluginName: longName
17+
};
18+
throw whitespaceError;
19+
}
20+
21+
if (!plugins[shortName]) {
22+
try {
23+
plugin = require(longName);
24+
} catch (pluginLoadErr) {
25+
try {
26+
// Check whether the plugin exists
27+
require.resolve(longName);
28+
} catch (missingPluginErr) {
29+
// If the plugin can't be resolved, display the missing plugin error (usually a config or install error)
30+
console.error(chalk.red(`Failed to load plugin ${longName}.`));
31+
missingPluginErr.message = `Failed to load plugin ${pluginName}: ${
32+
missingPluginErr.message
33+
}`;
34+
missingPluginErr.messageTemplate = 'plugin-missing';
35+
missingPluginErr.messageData = {
36+
pluginName: longName,
37+
commitlintPath: path.resolve(__dirname, '../..')
38+
};
39+
throw missingPluginErr;
40+
}
41+
42+
// Otherwise, the plugin exists and is throwing on module load for some reason, so print the stack trace.
43+
throw pluginLoadErr;
44+
}
45+
46+
// This step is costly, so skip if debug is disabled
47+
if (debug) {
48+
const resolvedPath = require.resolve(longName);
49+
50+
let version = null;
51+
52+
try {
53+
version = require(`${longName}/package.json`).version;
54+
} catch (e) {
55+
// Do nothing
56+
}
57+
58+
const loadedPluginAndVersion = version
59+
? `${longName}@${version}`
60+
: `${longName}, version unknown`;
61+
62+
console.log(
63+
chalk.blue(
64+
`Loaded plugin ${pluginName} (${loadedPluginAndVersion}) (from ${resolvedPath})`
65+
)
66+
);
67+
}
68+
69+
plugins[pluginName] = plugin;
70+
}
71+
}
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// largely adapted from eslint's plugin system
2+
const NAMESPACE_REGEX = /^@.*\//iu;
3+
// In eslint this is a parameter - we don't need to support the extra options
4+
const prefix = 'commitlint-plugin';
5+
6+
// Replace Windows with posix style paths
7+
function convertPathToPosix(filepath) {
8+
const normalizedFilepath = path.normalize(filepath);
9+
const posixFilepath = normalizedFilepath.replace(/\\/gu, '/');
10+
11+
return posixFilepath;
12+
}
13+
14+
/**
15+
* Brings package name to correct format based on prefix
16+
* @param {string} name The name of the package.
17+
* @returns {string} Normalized name of the package
18+
* @private
19+
*/
20+
export function normalizePackageName(name) {
21+
let normalizedName = name;
22+
23+
/**
24+
* On Windows, name can come in with Windows slashes instead of Unix slashes.
25+
* Normalize to Unix first to avoid errors later on.
26+
* https://github.com/eslint/eslint/issues/5644
27+
*/
28+
if (normalizedName.indexOf('\\') > -1) {
29+
normalizedName = convertPathToPosix(normalizedName);
30+
}
31+
32+
if (normalizedName.charAt(0) === '@') {
33+
/**
34+
* it's a scoped package
35+
* package name is the prefix, or just a username
36+
*/
37+
const scopedPackageShortcutRegex = new RegExp(
38+
`^(@[^/]+)(?:/(?:${prefix})?)?$`,
39+
'u'
40+
),
41+
scopedPackageNameRegex = new RegExp(`^${prefix}(-|$)`, 'u');
42+
43+
if (scopedPackageShortcutRegex.test(normalizedName)) {
44+
normalizedName = normalizedName.replace(
45+
scopedPackageShortcutRegex,
46+
`$1/${prefix}`
47+
);
48+
} else if (!scopedPackageNameRegex.test(normalizedName.split('/')[1])) {
49+
/**
50+
* for scoped packages, insert the prefix after the first / unless
51+
* the path is already @scope/eslint or @scope/eslint-xxx-yyy
52+
*/
53+
normalizedName = normalizedName.replace(
54+
/^@([^/]+)\/(.*)$/u,
55+
`@$1/${prefix}-$2`
56+
);
57+
}
58+
} else if (normalizedName.indexOf(`${prefix}-`) !== 0) {
59+
normalizedName = `${prefix}-${normalizedName}`;
60+
}
61+
62+
return normalizedName;
63+
}
64+
65+
/**
66+
* Removes the prefix from a fullname.
67+
* @param {string} fullname The term which may have the prefix.
68+
* @returns {string} The term without prefix.
69+
*/
70+
export function getShorthandName(fullname) {
71+
if (fullname[0] === '@') {
72+
let matchResult = new RegExp(`^(@[^/]+)/${prefix}$`, 'u').exec(fullname);
73+
74+
if (matchResult) {
75+
return matchResult[1];
76+
}
77+
78+
matchResult = new RegExp(`^(@[^/]+)/${prefix}-(.+)$`, 'u').exec(fullname);
79+
if (matchResult) {
80+
return `${matchResult[1]}/${matchResult[2]}`;
81+
}
82+
} else if (fullname.startsWith(`${prefix}-`)) {
83+
return fullname.slice(prefix.length + 1);
84+
}
85+
86+
return fullname;
87+
}
88+
89+
/**
90+
* Gets the scope (namespace) of a term.
91+
* @param {string} term The term which may have the namespace.
92+
* @returns {string} The namepace of the term if it has one.
93+
*/
94+
export function getNamespaceFromTerm(term) {
95+
const match = term.match(NAMESPACE_REGEX);
96+
97+
return match ? match[0] : '';
98+
}

docs/reference-plugins.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Working with Plugins
2+
3+
Each plugin is an npm module with a name in the format of `commitlint-plugin-<plugin-name>`, such as `commitlint-plugin-jquery`. You can also use scoped packages in the format of `@<scope>/commitlint-plugin-<plugin-name>` such as `@jquery/commitlint-plugin-jquery`.
4+
5+
### Rules in Plugins
6+
7+
Plugins can expose additional rules for use in commitlint. To do so, the plugin must export a `rules` object containing a key-value mapping of rule ID to rule. The rule ID does not have to follow any naming convention (so it can just be `dollar-sign`, for instance).
8+
9+
```js
10+
module.exports = {
11+
rules: {
12+
"dollar-sign": function(parsed, when, value) {
13+
// rule implementation ...
14+
}
15+
}
16+
};
17+
```
18+
19+
To use the rule in commitlint, you would use the unprefixed plugin name, followed by a slash, followed by the rule name. So if this plugin were named `commitlint-plugin-myplugin`, then in your configuration you'd refer to the rule by the name `myplugin/dollar-sign`. Example: `"rules": {"myplugin/dollar-sign": 2}`.
20+
21+
### Peer Dependency
22+
23+
To make clear that the plugin requires commitlint to work correctly you have to declare commitlint as a `peerDependency` in your `package.json`.
24+
The plugin support was introduced in commitlint version `7.6.0`. Ensure the `peerDependency` points to @commitlint `7.6.0` or later.
25+
26+
```json
27+
{
28+
"peerDependencies": {
29+
"@commitlint/lint": ">=7.6.0"
30+
}
31+
}
32+
```
33+
34+
## Share Plugins
35+
36+
In order to make your plugin available to the community you have to publish it on npm.
37+
38+
Recommended keywords:
39+
40+
* `commitlint`
41+
* `commitlintplugin`
42+
43+
Add these keywords into your `package.json` file to make it easy for others to find.
44+
45+
## Further Reading
46+
47+
* [npm Developer Guide](https://docs.npmjs.com/misc/developers)

0 commit comments

Comments
 (0)