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 library of rules to enforce standards and consistency for Markdown files. It
was inspired by - and heavily influenced by - Mark Harrison's was inspired by - and heavily influenced by - Mark Harrison's
[markdownlint](https://github.com/mivok/markdownlint) for [markdownlint](https://github.com/mivok/markdownlint) for
[Ruby](https://www.ruby-lang.org/). The rules, rule documentation, and test [Ruby](https://www.ruby-lang.org/). The initial rules, rule documentation, and
cases come directly from that project. test cases came directly from that project.
### Related ### Related
@ -142,11 +142,13 @@ space * in * emphasis <!-- markdownlint-disable --> <!-- markdownlint-enable -->
## API ## API
### Linting
Standard asynchronous interface: Standard asynchronous interface:
```js ```js
/** /**
* Lint specified Markdown files according to configurable rules. * Lint specified Markdown files.
* *
* @param {Object} options Configuration options. * @param {Object} options Configuration options.
* @param {Function} callback Callback (err, result) function. * @param {Function} callback Callback (err, result) function.
@ -159,7 +161,7 @@ Synchronous interface (for build scripts, etc.):
```js ```js
/** /**
* Lint specified Markdown files according to configurable rules. * Lint specified Markdown files synchronously.
* *
* @param {Object} options Configuration options. * @param {Object} options Configuration options.
* @returns {Object} Result object. * @returns {Object} Result object.
@ -167,13 +169,13 @@ Synchronous interface (for build scripts, etc.):
function markdownlint.sync(options) { ... } function markdownlint.sync(options) { ... }
``` ```
### options #### options
Type: `Object` Type: `Object`
Configures the function. Configures the function.
#### options.files ##### options.files
Type: `Array` of `String` Type: `Array` of `String`
@ -185,7 +187,7 @@ responsibility.
Example: `[ "one.md", "dir/two.md" ]` Example: `[ "one.md", "dir/two.md" ]`
#### options.strings ##### options.strings
Type: `Object` mapping `String` to `String` Type: `Object` mapping `String` to `String`
@ -204,7 +206,7 @@ Example:
} }
``` ```
#### options.frontMatter ##### options.frontMatter
Type: `RegExp` Type: `RegExp`
@ -232,7 +234,7 @@ title: Title
--- ---
``` ```
#### options.config ##### options.config
Type: `Object` mapping `String` to `Boolean | Object` 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) See [markdownlint-config-schema.json](schema/markdownlint-config-schema.json)
for the [JSON Schema](http://json-schema.org/) of the `options.config` object. 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` 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, error includes information about the line number, rule name, alias, description,
as well as any additional detail or context that is available. as well as any additional detail or context that is available.
### callback #### callback
Type: `Function` taking (`Error`, `Object`) Type: `Function` taking (`Error`, `Object`)
Standard completion callback. Standard completion callback.
### result #### result
Type: `Object` 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()` structure of the `result` object. Passing the value `true` to `toString()`
uses rule aliases (ex: `no-hard-tabs`) instead of names (ex: `MD010`). 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 ## Usage
Invoke `markdownlint` and use the `result` object's `toString` method: Invoke `markdownlint` and use the `result` object's `toString` method:

View file

@ -1,6 +1,7 @@
"use strict"; "use strict";
var fs = require("fs"); var fs = require("fs");
var path = require("path");
var md = require("markdown-it")({ "html": true }); var md = require("markdown-it")({ "html": true });
var rules = require("./rules"); var rules = require("./rules");
var shared = require("./shared"); 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 {Object} options Configuration options.
* @param {Function} callback Callback (err, result) function. * @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. * @param {Object} options Configuration options.
* @returns {Object} Result object. * @returns {Object} Result object.
@ -396,6 +397,63 @@ function markdownlintSync(options) {
return markdownlint(options, markdownlintSynchronousCallback); 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 // Export a/synchronous APIs
module.exports = markdownlint; module.exports = markdownlint;
module.exports.sync = markdownlintSync; 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", "description": "Default state for all rules",
"type": "boolean", "type": "boolean",
"default": true "default": true
},
"extends": {
"description": "Path to configuration file to extend",
"type": "string",
"default": null
} }
}, },
"additionalProperties": false "additionalProperties": false

View file

@ -7,6 +7,11 @@
"type": "boolean", "type": "boolean",
"default": true "default": true
}, },
"extends": {
"description": "Path to configuration file to extend",
"type": "string",
"default": null
},
"MD001": { "MD001": {
"description": "MD001/header-increment - Header levels should only increment by one level at a time", "description": "MD001/header-increment - Header levels should only increment by one level at a time",
"type": "boolean", "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) { markdownlint(options, function callback(err, actual) {
test.ifError(err); test.ifError(err);
var expected = { "README.md": {} }; var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(actual, expected, "Issue(s) with project files."); test.deepEqual(actual, expected, "Issue(s) with project files.");
test.done(); test.done();
}); });
@ -782,14 +782,19 @@ module.exports.readmeHeaders = function readmeHeaders(test) {
"## Tags", "## Tags",
"## Configuration", "## Configuration",
"## API", "## API",
"### options", "### Linting",
"#### options.files", "#### options",
"#### options.strings", "##### options.files",
"#### options.frontMatter", "##### options.strings",
"#### options.config", "##### options.frontMatter",
"#### options.resultVersion", "##### options.config",
"### callback", "##### options.resultVersion",
"### result", "#### callback",
"#### result",
"### Config",
"#### file",
"#### callback",
"#### result",
"## Usage", "## Usage",
"## Browser", "## Browser",
"## History" "## History"
@ -798,7 +803,7 @@ module.exports.readmeHeaders = function readmeHeaders(test) {
} }
}, function callback(err, result) { }, function callback(err, result) {
test.ifError(err); test.ifError(err);
var expected = { "README.md": {} }; var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(result, expected, "Unexpected issues."); test.deepEqual(result, expected, "Unexpected issues.");
test.done(); test.done();
}); });
@ -825,7 +830,7 @@ module.exports.filesArrayAsString = function filesArrayAsString(test) {
"config": { "MD013": { "line_length": 150 } } "config": { "MD013": { "line_length": 150 } }
}, function callback(err, actual) { }, function callback(err, actual) {
test.ifError(err); test.ifError(err);
var expected = { "README.md": {} }; var expected = { "README.md": { "MD024": [ 392, 398 ] } };
test.deepEqual(actual, expected, "Unexpected issues."); test.deepEqual(actual, expected, "Unexpected issues.");
test.done(); test.done();
}); });
@ -1109,3 +1114,166 @@ module.exports.trimPolyfills = function trimPolyfills(test) {
}); });
test.done(); 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();
};