diff --git a/README.md b/README.md index 3815fc94..d14fe299 100644 --- a/README.md +++ b/README.md @@ -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 ## 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: diff --git a/lib/markdownlint.js b/lib/markdownlint.js index c714402b..fd37297e 100644 --- a/lib/markdownlint.js +++ b/lib/markdownlint.js @@ -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; diff --git a/schema/build-config-schema.js b/schema/build-config-schema.js index 7de4b4c5..6ed88199 100644 --- a/schema/build-config-schema.js +++ b/schema/build-config-schema.js @@ -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 diff --git a/schema/markdownlint-config-schema.json b/schema/markdownlint-config-schema.json index c43e6538..b6da4001 100644 --- a/schema/markdownlint-config-schema.json +++ b/schema/markdownlint-config-schema.json @@ -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", diff --git a/test/config-child.json b/test/config-child.json new file mode 100644 index 00000000..f56f00bf --- /dev/null +++ b/test/config-child.json @@ -0,0 +1,4 @@ +{ + "no-hard-tabs": false, + "whitespace": false +} diff --git a/test/config-grandparent.json b/test/config-grandparent.json new file mode 100644 index 00000000..4a45360c --- /dev/null +++ b/test/config-grandparent.json @@ -0,0 +1,6 @@ +{ + "extends": "config-parent.json", + "MD003": { "style": "atx_closed" }, + "MD007": { "indent": 2 }, + "no-hard-tabs": false +} diff --git a/test/config-parent.json b/test/config-parent.json new file mode 100644 index 00000000..172b646c --- /dev/null +++ b/test/config-parent.json @@ -0,0 +1,7 @@ +{ + "extends": "config-child.json", + "MD003": false, + "MD007": { "indent": 4 }, + "no-hard-tabs": true, + "line-length": { "line_length": 200 } +} diff --git a/test/config/config-badchildfile.json b/test/config/config-badchildfile.json new file mode 100644 index 00000000..774a3b8a --- /dev/null +++ b/test/config/config-badchildfile.json @@ -0,0 +1,4 @@ +{ + "extends": "config-badfile.json", + "default": true +} diff --git a/test/config/config-badchildjson.json b/test/config/config-badchildjson.json new file mode 100644 index 00000000..551ecb41 --- /dev/null +++ b/test/config/config-badchildjson.json @@ -0,0 +1,4 @@ +{ + "extends": "config-badjson.json", + "default": true +} diff --git a/test/config/config-badjson.json b/test/config/config-badjson.json new file mode 100644 index 00000000..9f37b62b --- /dev/null +++ b/test/config/config-badjson.json @@ -0,0 +1,3 @@ +{ + bad json +} diff --git a/test/markdownlint-test.js b/test/markdownlint-test.js index f73cbe55..4e87daf1 100644 --- a/test/markdownlint-test.js +++ b/test/markdownlint-test.js @@ -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(); +};