Add support for shareable/extendable configuration via "extends" and helper functions (fixes #33).

This commit is contained in:
David Anson 2017-05-19 22:36:46 -07:00
parent d826833a82
commit 7528295cae
11 changed files with 385 additions and 25 deletions

120
README.md
View file

@ -28,8 +28,8 @@ tool for [Node.js](https://nodejs.org/) and [io.js](https://iojs.org/) with a
library of rules to enforce standards and consistency for Markdown files. It
was inspired by - and heavily influenced by - Mark Harrison's
[markdownlint](https://github.com/mivok/markdownlint) for
[Ruby](https://www.ruby-lang.org/). The rules, rule documentation, and test
cases come directly from that project.
[Ruby](https://www.ruby-lang.org/). The initial rules, rule documentation, and
test cases came directly from that project.
### Related
@ -142,11 +142,13 @@ space * in * emphasis <!-- markdownlint-disable --> <!-- markdownlint-enable -->
## API
### Linting
Standard asynchronous interface:
```js
/**
* Lint specified Markdown files according to configurable rules.
* Lint specified Markdown files.
*
* @param {Object} options Configuration options.
* @param {Function} callback Callback (err, result) function.
@ -159,7 +161,7 @@ Synchronous interface (for build scripts, etc.):
```js
/**
* Lint specified Markdown files according to configurable rules.
* Lint specified Markdown files synchronously.
*
* @param {Object} options Configuration options.
* @returns {Object} Result object.
@ -167,13 +169,13 @@ Synchronous interface (for build scripts, etc.):
function markdownlint.sync(options) { ... }
```
### options
#### options
Type: `Object`
Configures the function.
#### options.files
##### options.files
Type: `Array` of `String`
@ -185,7 +187,7 @@ responsibility.
Example: `[ "one.md", "dir/two.md" ]`
#### options.strings
##### options.strings
Type: `Object` mapping `String` to `String`
@ -204,7 +206,7 @@ Example:
}
```
#### options.frontMatter
##### options.frontMatter
Type: `RegExp`
@ -232,7 +234,7 @@ title: Title
---
```
#### options.config
##### options.config
Type: `Object` mapping `String` to `Boolean | Object`
@ -277,7 +279,46 @@ See the [style](style) directory for more samples.
See [markdownlint-config-schema.json](schema/markdownlint-config-schema.json)
for the [JSON Schema](http://json-schema.org/) of the `options.config` object.
#### options.resultVersion
For more advanced scenarios, styles can reference and extend other styles. The
`readConfig` and `readConfigSync` functions can be used to read such styles.
For example, assuming a `base.json` configuration file:
```json
{
"default": true
}
```
And a `custom.json` configuration file:
```json
{
"extends": "base.json",
"line-length": false
}
```
Then code like the following:
```js
var options = {
"config": markdownlint.readConfigSync("./custom.json")
};
```
Merges `custom.json` and `base.json` and is equivalent to:
```js
var options = {
"config": {
"default": true,
"line-length": false
}
};
```
##### options.resultVersion
Type: `Number`
@ -291,13 +332,13 @@ Passing a `resultVersion` of `1` corresponds to a more detailed format where eac
error includes information about the line number, rule name, alias, description,
as well as any additional detail or context that is available.
### callback
#### callback
Type: `Function` taking (`Error`, `Object`)
Standard completion callback.
### result
#### result
Type: `Object`
@ -305,6 +346,61 @@ Call `result.toString()` for convenience or see below for an example of the
structure of the `result` object. Passing the value `true` to `toString()`
uses rule aliases (ex: `no-hard-tabs`) instead of names (ex: `MD010`).
### Config
The `options.config` configuration object is simple and can be loaded as JSON
in many cases. To take advantage of shared configuration where one file `extends`
another, the following functions are useful.
Asynchronous interface:
```js
/**
* Read specified configuration file.
*
* @param {String} file Configuration file name/path.
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, callback) { ... }
```
Synchronous interface:
```js
/**
* Read specified configuration file synchronously.
*
* @param {String} file Configuration file name/path.
* @returns {Object} Configuration object.
*/
function readConfigSync(file) { ... }
```
#### file
Type: `String`
Location of JSON configuration file to read.
The `file` is resolved relative to the current working directory. If an `extends`
key is present once read, its value will be resolved as a path relative to `file`
and loaded recursively. Settings from a file referenced by `extends` are applied
first, then those of `file` are applied on top (overriding any of the same keys
appearing in the referenced file).
#### callback
Type: `Function` taking (`Error`, `Object`)
Standard completion callback.
#### result
Type: `Object`
Configuration object.
## Usage
Invoke `markdownlint` and use the `result` object's `toString` method:

View file

@ -1,6 +1,7 @@
"use strict";
var fs = require("fs");
var path = require("path");
var md = require("markdown-it")({ "html": true });
var rules = require("./rules");
var shared = require("./shared");
@ -329,7 +330,7 @@ function markdownlintSynchronousCallback() {
}
/**
* Lint specified Markdown files according to configurable rules.
* Lint specified Markdown files.
*
* @param {Object} options Configuration options.
* @param {Function} callback Callback (err, result) function.
@ -387,7 +388,7 @@ function markdownlint(options, callback) {
}
/**
* Lint specified Markdown files according to configurable rules.
* Lint specified Markdown files synchronously.
*
* @param {Object} options Configuration options.
* @returns {Object} Result object.
@ -396,6 +397,63 @@ function markdownlintSync(options) {
return markdownlint(options, markdownlintSynchronousCallback);
}
/**
* Read specified configuration file.
*
* @param {String} file Configuration file name/path.
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, callback) {
// Read file
fs.readFile(file, shared.utf8Encoding, function handleFile(err, content) {
if (err) {
return callback(err);
}
// Parse file
var config = null;
try {
config = JSON.parse(content);
} catch (ex) {
return callback(ex);
}
if (config.extends) {
// Extend configuration
var extendsFile = path.resolve(path.dirname(file), config.extends);
readConfig(extendsFile, function handleConfig(errr, extendsConfig) {
if (errr) {
return callback(errr);
}
delete config.extends;
callback(null, shared.assign(extendsConfig, config));
});
} else {
callback(null, config);
}
});
}
/**
* Read specified configuration file synchronously.
*
* @param {String} file Configuration file name/path.
* @returns {Object} Configuration object.
*/
function readConfigSync(file) {
// Parse file
var config = JSON.parse(fs.readFileSync(file, shared.utf8Encoding));
if (config.extends) {
// Extend configuration
config = shared.assign(
readConfigSync(path.resolve(path.dirname(file), config.extends)),
config);
delete config.extends;
}
return config;
}
// Export a/synchronous APIs
module.exports = markdownlint;
module.exports.sync = markdownlintSync;
module.exports.readConfig = readConfig;
module.exports.readConfigSync = readConfigSync;

View file

@ -13,6 +13,11 @@ var schema = {
"description": "Default state for all rules",
"type": "boolean",
"default": true
},
"extends": {
"description": "Path to configuration file to extend",
"type": "string",
"default": null
}
},
"additionalProperties": false

View file

@ -7,6 +7,11 @@
"type": "boolean",
"default": true
},
"extends": {
"description": "Path to configuration file to extend",
"type": "string",
"default": null
},
"MD001": {
"description": "MD001/header-increment - Header levels should only increment by one level at a time",
"type": "boolean",

4
test/config-child.json Normal file
View file

@ -0,0 +1,4 @@
{
"no-hard-tabs": false,
"whitespace": false
}

View file

@ -0,0 +1,6 @@
{
"extends": "config-parent.json",
"MD003": { "style": "atx_closed" },
"MD007": { "indent": 2 },
"no-hard-tabs": false
}

7
test/config-parent.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "config-child.json",
"MD003": false,
"MD007": { "indent": 4 },
"no-hard-tabs": true,
"line-length": { "line_length": 200 }
}

View file

@ -0,0 +1,4 @@
{
"extends": "config-badfile.json",
"default": true
}

View file

@ -0,0 +1,4 @@
{
"extends": "config-badjson.json",
"default": true
}

View file

@ -0,0 +1,3 @@
{
bad json
}

View file

@ -87,7 +87,7 @@ module.exports.projectFiles = function projectFiles(test) {
};
markdownlint(options, function callback(err, actual) {
test.ifError(err);
var expected = { "README.md": {} };
var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(actual, expected, "Issue(s) with project files.");
test.done();
});
@ -782,14 +782,19 @@ module.exports.readmeHeaders = function readmeHeaders(test) {
"## Tags",
"## Configuration",
"## API",
"### options",
"#### options.files",
"#### options.strings",
"#### options.frontMatter",
"#### options.config",
"#### options.resultVersion",
"### callback",
"### result",
"### Linting",
"#### options",
"##### options.files",
"##### options.strings",
"##### options.frontMatter",
"##### options.config",
"##### options.resultVersion",
"#### callback",
"#### result",
"### Config",
"#### file",
"#### callback",
"#### result",
"## Usage",
"## Browser",
"## History"
@ -798,7 +803,7 @@ module.exports.readmeHeaders = function readmeHeaders(test) {
}
}, function callback(err, result) {
test.ifError(err);
var expected = { "README.md": {} };
var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(result, expected, "Unexpected issues.");
test.done();
});
@ -825,7 +830,7 @@ module.exports.filesArrayAsString = function filesArrayAsString(test) {
"config": { "MD013": { "line_length": 150 } }
}, function callback(err, actual) {
test.ifError(err);
var expected = { "README.md": {} };
var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(actual, expected, "Unexpected issues.");
test.done();
});
@ -1109,3 +1114,166 @@ module.exports.trimPolyfills = function trimPolyfills(test) {
});
test.done();
};
module.exports.configSingle = function configSingle(test) {
test.expect(2);
markdownlint.readConfig("./test/config-child.json",
function callback(err, actual) {
test.ifError(err);
var expected = require("./config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configAbsolute = function configAbsolute(test) {
test.expect(2);
markdownlint.readConfig(path.join(__dirname, "config-child.json"),
function callback(err, actual) {
test.ifError(err);
var expected = require("./config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configMultiple = function configMultiple(test) {
test.expect(2);
markdownlint.readConfig("./test/config-grandparent.json",
function callback(err, actual) {
test.ifError(err);
var expected = shared.assign(shared.assign(shared.assign({},
require("./config-child.json")),
require("./config-parent.json")),
require("./config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configBadFile = function configBadFile(test) {
test.expect(4);
markdownlint.readConfig("./test/config/config-badfile.json",
function callback(err, result) {
test.ok(err, "Did not get an error for bad file.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.equal(err.code, "ENOENT", "Error code for bad file not ENOENT.");
test.ok(!result, "Got result for bad file.");
test.done();
});
};
module.exports.configBadChildFile = function configBadChildFile(test) {
test.expect(4);
markdownlint.readConfig("./test/config/config-badchildfile.json",
function callback(err, result) {
test.ok(err, "Did not get an error for bad child file.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.equal(err.code, "ENOENT",
"Error code for bad child file not ENOENT.");
test.ok(!result, "Got result for bad child file.");
test.done();
});
};
module.exports.configBadJson = function configBadJson(test) {
test.expect(3);
markdownlint.readConfig("./test/config/config-badjson.json",
function callback(err, result) {
test.ok(err, "Did not get an error for bad JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(!result, "Got result for bad JSON.");
test.done();
});
};
module.exports.configBadChildJson = function configBadChildJson(test) {
test.expect(3);
markdownlint.readConfig("./test/config/config-badchildjson.json",
function callback(err, result) {
test.ok(err, "Did not get an error for bad child JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(!result, "Got result for bad child JSON.");
test.done();
});
};
module.exports.configSingleSync = function configSingleSync(test) {
test.expect(1);
var actual = markdownlint.readConfigSync("./test/config-child.json");
var expected = require("./config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configAbsoluteSync = function configAbsoluteSync(test) {
test.expect(1);
var actual = markdownlint.readConfigSync(
path.join(__dirname, "config-child.json"));
var expected = require("./config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configMultipleSync = function configMultipleSync(test) {
test.expect(1);
var actual = markdownlint.readConfigSync("./test/config-grandparent.json");
var expected = shared.assign(shared.assign(shared.assign({},
require("./config-child.json")),
require("./config-parent.json")),
require("./config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configBadFileSync = function configBadFileSync(test) {
test.expect(4);
test.throws(function badFileCall() {
markdownlint.readConfigSync("./test/config-badfile.json");
}, function testError(err) {
test.ok(err, "Did not get an error for bad file.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.equal(err.code, "ENOENT", "Error code for bad file not ENOENT.");
return true;
}, "Did not get exception for bad file.");
test.done();
};
module.exports.configBadChildFileSync = function configBadChildFileSync(test) {
test.expect(4);
test.throws(function badChildFileCall() {
markdownlint.readConfigSync("./test/config-badfile.json");
}, function testError(err) {
test.ok(err, "Did not get an error for bad child file.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.equal(err.code, "ENOENT", "Error code for bad child file not ENOENT.");
return true;
}, "Did not get exception for bad child file.");
test.done();
};
module.exports.configBadJsonSync = function configBadJsonSync(test) {
test.expect(3);
test.throws(function badJsonCall() {
markdownlint.readConfigSync("./test/config-badjson.json");
}, function testError(err) {
test.ok(err, "Did not get an error for bad JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
return true;
}, "Did not get exception for bad JSON.");
test.done();
};
module.exports.configBadChildJsonSync = function configBadChildJsonSync(test) {
test.expect(3);
test.throws(function badChildJsonCall() {
markdownlint.readConfigSync("./test/config-badchildjson.json");
}, function testError(err) {
test.ok(err, "Did not get an error for bad child JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
return true;
}, "Did not get exception for bad child JSON.");
test.done();
};