Add "configParsers" option so custom parsers can be used to handle the content of markdownlint-configure-file inline comments (fixes #528).

This commit is contained in:
David Anson 2022-06-05 22:32:22 -07:00
parent bbec8c5c1e
commit 00082ee8a5
6 changed files with 267 additions and 108 deletions

View file

@ -263,8 +263,11 @@ or
-->
```
These changes apply to the entire file regardless of where the comment
is located. Multiple such comments (if present) are applied top-to-bottom.
These changes apply to the entire file regardless of where the comment is
located. Multiple such comments (if present) are applied top-to-bottom. By
default, content of `markdownlint-configure-file` is assumed to be JSON, but
[`options.configParsers`](#optionsconfigparsers) can be used to support
alternate formats.
## API
@ -406,6 +409,24 @@ const options = {
};
```
##### options.configParsers
Type: *Optional* `Array` of `Function` taking (`String`) and returning `Object`
Array of functions to parse the content of `markdownlint-configure-file` blocks.
As shown in the [Configuration](#configuration) section, inline comments can be
used to customize the [configuration object](#optionsconfig) for a document. By
default, the `JSON.parse` built-in is used, but custom parsers can be specified.
Content is passed to each parser function until one returns a value (vs. throwing
an exception). As such, strict parsers should come before flexible ones.
For example:
```js
[ JSON.parse, require("toml").parse, require("js-yaml").load ]
```
##### options.customRules
Type: `Array` of `Object`

View file

@ -1560,6 +1560,39 @@ function getEffectiveConfig(ruleList, config, aliasToRuleNames) {
});
return effectiveConfig;
}
/**
* Parse the content of a configuration file.
*
* @param {string} name Name of the configuration file.
* @param {string} content Configuration content.
* @param {ConfigurationParser[]} parsers Parsing function(s).
* @returns {Object} Configuration object and error message.
*/
function parseConfiguration(name, content, parsers) {
let config = null;
let message = "";
const errors = [];
let index = 0;
// Try each parser
(parsers || [JSON.parse]).every((parser) => {
try {
config = parser(content);
}
catch (error) {
errors.push(`Parser ${index++}: ${error.message}`);
}
return !config;
});
// Message if unable to parse
if (!config) {
errors.unshift(`Unable to parse '${name}'`);
message = errors.join("; ");
}
return {
config,
message
};
}
/**
* Create a mapping of enabled rules per line.
*
@ -1568,11 +1601,12 @@ function getEffectiveConfig(ruleList, config, aliasToRuleNames) {
* @param {string[]} frontMatterLines List of front matter lines.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {Object.<string, string[]>} aliasToRuleNames Map of alias to rule
* names.
* @returns {Object} Effective configuration and enabled rules per line number.
*/
function getEnabledRulesPerLineNumber(ruleList, lines, frontMatterLines, noInlineConfig, config, aliasToRuleNames) {
function getEnabledRulesPerLineNumber(ruleList, lines, frontMatterLines, noInlineConfig, config, configParsers, aliasToRuleNames) {
// Shared variables
let enabledRules = {};
let capturedRules = {};
@ -1603,12 +1637,9 @@ function getEnabledRulesPerLineNumber(ruleList, lines, frontMatterLines, noInlin
// eslint-disable-next-line jsdoc/require-jsdoc
function configureFile(action, parameter) {
if (action === "CONFIGURE-FILE") {
try {
const json = JSON.parse(parameter);
config = Object.assign(Object.assign({}, config), json);
}
catch (_a) {
// Ignore parse errors for inline configuration
const { "config": parsed } = parseConfiguration("CONFIGURE-FILE", parameter, configParsers);
if (parsed) {
config = Object.assign(Object.assign({}, config), parsed);
}
}
}
@ -1683,6 +1714,7 @@ function getEnabledRulesPerLineNumber(ruleList, lines, frontMatterLines, noInlin
* @param {string} content Markdown content.
* @param {Object} md Instance of markdown-it.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {RegExp} frontMatter Regular expression for front matter.
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
@ -1690,7 +1722,7 @@ function getEnabledRulesPerLineNumber(ruleList, lines, frontMatterLines, noInlin
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function lintContent(ruleList, name, content, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, callback) {
function lintContent(ruleList, name, content, md, config, configParsers, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, callback) {
// Remove UTF-8 byte order marker (if present)
content = content.replace(/^\uFEFF/, "");
// Remove front matter
@ -1698,7 +1730,7 @@ function lintContent(ruleList, name, content, md, config, frontMatter, handleRul
const { frontMatterLines } = removeFrontMatterResult;
content = removeFrontMatterResult.content;
// Get enabled rules per line (with HTML comments present)
const { effectiveConfig, enabledRulesPerLineNumber } = getEnabledRulesPerLineNumber(ruleList, content.split(helpers.newLineRe), frontMatterLines, noInlineConfig, config, mapAliasToRuleNames(ruleList));
const { effectiveConfig, enabledRulesPerLineNumber } = getEnabledRulesPerLineNumber(ruleList, content.split(helpers.newLineRe), frontMatterLines, noInlineConfig, config, configParsers, mapAliasToRuleNames(ruleList));
// Hide the content of HTML comments from rules, etc.
content = helpers.clearHtmlCommentText(content);
// Parse content into tokens and lines
@ -1924,6 +1956,7 @@ function lintContent(ruleList, name, content, md, config, frontMatter, handleRul
* @param {string} file Path of file to lint.
* @param {Object} md Instance of markdown-it.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {RegExp} frontMatter Regular expression for front matter.
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
@ -1933,13 +1966,13 @@ function lintContent(ruleList, name, content, md, config, frontMatter, handleRul
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function lintFile(ruleList, file, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, callback) {
function lintFile(ruleList, file, md, config, configParsers, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, callback) {
// eslint-disable-next-line jsdoc/require-jsdoc
function lintContentWrapper(err, content) {
if (err) {
return callback(err);
}
return lintContent(ruleList, file, content, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, callback);
return lintContent(ruleList, file, content, md, config, configParsers, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, callback);
}
// Make a/synchronous call to read file
if (synchronous) {
@ -1977,6 +2010,7 @@ function lintInput(options, synchronous, callback) {
const strings = options.strings || {};
const stringsKeys = Object.keys(strings);
const config = options.config || { "default": true };
const configParsers = options.configParsers || null;
const frontMatter = (options.frontMatter === undefined) ?
helpers.frontMatterRe : options.frontMatter;
const handleRuleFailures = !!options.handleRuleFailures;
@ -2016,13 +2050,13 @@ function lintInput(options, synchronous, callback) {
// Lint next file
concurrency++;
currentItem = files.shift();
lintFile(ruleList, currentItem, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, lintWorkerCallback);
lintFile(ruleList, currentItem, md, config, configParsers, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, lintWorkerCallback);
}
else if (stringsKeys.length > 0) {
// Lint next string
concurrency++;
currentItem = stringsKeys.shift();
lintContent(ruleList, currentItem, strings[currentItem] || "", md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, lintWorkerCallback);
lintContent(ruleList, currentItem, strings[currentItem] || "", md, config, configParsers, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, lintWorkerCallback);
}
else if (concurrency === 0) {
// Finish
@ -2087,39 +2121,6 @@ function markdownlintSync(options) {
});
return results;
}
/**
* Parse the content of a configuration file.
*
* @param {string} name Name of the configuration file.
* @param {string} content Configuration content.
* @param {ConfigurationParser[]} parsers Parsing function(s).
* @returns {Object} Configuration object and error message.
*/
function parseConfiguration(name, content, parsers) {
let config = null;
let message = "";
const errors = [];
let index = 0;
// Try each parser
(parsers || [JSON.parse]).every((parser) => {
try {
config = parser(content);
}
catch (error) {
errors.push(`Parser ${index++}: ${error.message}`);
}
return !config;
});
// Message if unable to parse
if (!config) {
errors.unshift(`Unable to parse '${name}'`);
message = errors.join("; ");
}
return {
config,
message
};
}
/**
* Resolve referenced "extends" path in a configuration file
* using path.resolve() with require.resolve() as a fallback.

View file

@ -88,6 +88,7 @@ options = {
"code_blocks": true
}
},
"configParsers": [ JSON.parse ],
"customRules": undefined,
"frontMatter": /---/,
"handleRuleFailures": false,

36
lib/markdownlint.d.ts vendored
View file

@ -14,32 +14,38 @@ declare namespace markdownlint {
* Configuration options.
*/
type Options = {
/**
* Files to lint.
*/
files?: string[] | string;
/**
* Strings to lint.
*/
strings?: {
[x: string]: string;
};
/**
* Configuration object.
*/
config?: Configuration;
/**
* Configuration parsers.
*/
configParsers?: ConfigurationParser[];
/**
* Custom rules.
*/
customRules?: Rule[] | Rule;
/**
* Files to lint.
*/
files?: string[] | string;
/**
* Front matter pattern.
*/
frontMatter?: RegExp;
/**
* File system implementation.
*/
fs?: any;
/**
* True to catch exceptions.
*/
handleRuleFailures?: boolean;
/**
* Additional plugins.
*/
markdownItPlugins?: Plugin[];
/**
* True to ignore HTML directives.
*/
@ -49,13 +55,11 @@ type Options = {
*/
resultVersion?: number;
/**
* Additional plugins.
* Strings to lint.
*/
markdownItPlugins?: Plugin[];
/**
* File system implementation.
*/
fs?: any;
strings?: {
[x: string]: string;
};
};
/**
* Called with the result of the lint function.

View file

@ -316,6 +316,39 @@ function getEffectiveConfig(ruleList, config, aliasToRuleNames) {
return effectiveConfig;
}
/**
* Parse the content of a configuration file.
*
* @param {string} name Name of the configuration file.
* @param {string} content Configuration content.
* @param {ConfigurationParser[]} parsers Parsing function(s).
* @returns {Object} Configuration object and error message.
*/
function parseConfiguration(name, content, parsers) {
let config = null;
let message = "";
const errors = [];
let index = 0;
// Try each parser
(parsers || [ JSON.parse ]).every((parser) => {
try {
config = parser(content);
} catch (error) {
errors.push(`Parser ${index++}: ${error.message}`);
}
return !config;
});
// Message if unable to parse
if (!config) {
errors.unshift(`Unable to parse '${name}'`);
message = errors.join("; ");
}
return {
config,
message
};
}
/**
* Create a mapping of enabled rules per line.
*
@ -324,6 +357,7 @@ function getEffectiveConfig(ruleList, config, aliasToRuleNames) {
* @param {string[]} frontMatterLines List of front matter lines.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {Object.<string, string[]>} aliasToRuleNames Map of alias to rule
* names.
* @returns {Object} Effective configuration and enabled rules per line number.
@ -334,6 +368,7 @@ function getEnabledRulesPerLineNumber(
frontMatterLines,
noInlineConfig,
config,
configParsers,
aliasToRuleNames) {
// Shared variables
let enabledRules = {};
@ -365,14 +400,14 @@ function getEnabledRulesPerLineNumber(
// eslint-disable-next-line jsdoc/require-jsdoc
function configureFile(action, parameter) {
if (action === "CONFIGURE-FILE") {
try {
const json = JSON.parse(parameter);
const { "config": parsed } = parseConfiguration(
"CONFIGURE-FILE", parameter, configParsers
);
if (parsed) {
config = {
...config,
...json
...parsed
};
} catch {
// Ignore parse errors for inline configuration
}
}
}
@ -452,6 +487,7 @@ function getEnabledRulesPerLineNumber(
* @param {string} content Markdown content.
* @param {Object} md Instance of markdown-it.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {RegExp} frontMatter Regular expression for front matter.
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
@ -465,6 +501,7 @@ function lintContent(
content,
md,
config,
configParsers,
frontMatter,
handleRuleFailures,
noInlineConfig,
@ -484,6 +521,7 @@ function lintContent(
frontMatterLines,
noInlineConfig,
config,
configParsers,
mapAliasToRuleNames(ruleList)
);
// Hide the content of HTML comments from rules, etc.
@ -720,6 +758,7 @@ function lintContent(
* @param {string} file Path of file to lint.
* @param {Object} md Instance of markdown-it.
* @param {Configuration} config Configuration object.
* @param {ConfigurationParser[]} configParsers Configuration parsers.
* @param {RegExp} frontMatter Regular expression for front matter.
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
@ -734,6 +773,7 @@ function lintFile(
file,
md,
config,
configParsers,
frontMatter,
handleRuleFailures,
noInlineConfig,
@ -746,8 +786,19 @@ function lintFile(
if (err) {
return callback(err);
}
return lintContent(ruleList, file, content, md, config, frontMatter,
handleRuleFailures, noInlineConfig, resultVersion, callback);
return lintContent(
ruleList,
file,
content,
md,
config,
configParsers,
frontMatter,
handleRuleFailures,
noInlineConfig,
resultVersion,
callback
);
}
// Make a/synchronous call to read file
if (synchronous) {
@ -784,6 +835,7 @@ function lintInput(options, synchronous, callback) {
const strings = options.strings || {};
const stringsKeys = Object.keys(strings);
const config = options.config || { "default": true };
const configParsers = options.configParsers || null;
const frontMatter = (options.frontMatter === undefined) ?
helpers.frontMatterRe : options.frontMatter;
const handleRuleFailures = !!options.handleRuleFailures;
@ -827,6 +879,7 @@ function lintInput(options, synchronous, callback) {
currentItem,
md,
config,
configParsers,
frontMatter,
handleRuleFailures,
noInlineConfig,
@ -845,6 +898,7 @@ function lintInput(options, synchronous, callback) {
strings[currentItem] || "",
md,
config,
configParsers,
frontMatter,
handleRuleFailures,
noInlineConfig,
@ -918,39 +972,6 @@ function markdownlintSync(options) {
return results;
}
/**
* Parse the content of a configuration file.
*
* @param {string} name Name of the configuration file.
* @param {string} content Configuration content.
* @param {ConfigurationParser[]} parsers Parsing function(s).
* @returns {Object} Configuration object and error message.
*/
function parseConfiguration(name, content, parsers) {
let config = null;
let message = "";
const errors = [];
let index = 0;
// Try each parser
(parsers || [ JSON.parse ]).every((parser) => {
try {
config = parser(content);
} catch (error) {
errors.push(`Parser ${index++}: ${error.message}`);
}
return !config;
});
// Message if unable to parse
if (!config) {
errors.unshift(`Unable to parse '${name}'`);
message = errors.join("; ");
}
return {
config,
message
};
}
/**
* Resolve referenced "extends" path in a configuration file
* using path.resolve() with require.resolve() as a fallback.
@ -1237,16 +1258,17 @@ module.exports = markdownlint;
* Configuration options.
*
* @typedef {Object} Options
* @property {string[] | string} [files] Files to lint.
* @property {Object.<string, string>} [strings] Strings to lint.
* @property {Configuration} [config] Configuration object.
* @property {ConfigurationParser[]} [configParsers] Configuration parsers.
* @property {Rule[] | Rule} [customRules] Custom rules.
* @property {string[] | string} [files] Files to lint.
* @property {RegExp} [frontMatter] Front matter pattern.
* @property {Object} [fs] File system implementation.
* @property {boolean} [handleRuleFailures] True to catch exceptions.
* @property {Plugin[]} [markdownItPlugins] Additional plugins.
* @property {boolean} [noInlineConfig] True to ignore HTML directives.
* @property {number} [resultVersion] Results object version.
* @property {Plugin[]} [markdownItPlugins] Additional plugins.
* @property {Object} [fs] File system implementation.
* @property {Object.<string, string>} [strings] Strings to lint.
*/
/**

View file

@ -652,6 +652,7 @@ test.cb("readmeHeadings", (t) => {
"### Linting",
"#### options",
"##### options.config",
"##### options.configParsers",
"##### options.customRules",
"##### options.files",
"##### options.frontMatter",
@ -1277,6 +1278,115 @@ test("token-map-spans", (t) => {
markdownlint.sync(options);
});
test("configParsersInvalid", async(t) => {
t.plan(1);
const options = {
"strings": {
"content": [
"Text",
"",
"<!-- markdownlint-configure-file",
" \"first-line-heading\": false",
"-->",
""
].join("\n")
}
};
const expected = "content: 1: MD041/first-line-heading/first-line-h1 " +
"First line in a file should be a top-level heading [Context: \"Text\"]";
const actual = await markdownlint.promises.markdownlint(options);
t.is(actual.toString(), expected, "Unexpected results.");
});
test("configParsersJSON", async(t) => {
t.plan(1);
const options = {
"strings": {
"content": [
"Text",
"",
"<!-- markdownlint-configure-file",
"{",
" \"first-line-heading\": false",
"}",
"-->",
""
].join("\n")
}
};
const actual = await markdownlint.promises.markdownlint(options);
t.is(actual.toString(), "", "Unexpected results.");
});
test("configParsersJSONC", async(t) => {
t.plan(1);
// eslint-disable-next-line node/no-unsupported-features/es-syntax
const { "default": stripJsonComments } = await import("strip-json-comments");
const options = {
"strings": {
"content": [
"Text",
"",
"<!-- markdownlint-configure-file",
"/* Comment */",
"{",
" \"first-line-heading\": false // Comment",
"}",
"-->",
""
].join("\n")
},
"configParsers": [ (content) => JSON.parse(stripJsonComments(content)) ]
};
const actual = await markdownlint.promises.markdownlint(options);
t.is(actual.toString(), "", "Unexpected results.");
});
test("configParsersYAML", async(t) => {
t.plan(1);
const options = {
"strings": {
"content": [
"Text",
"",
"<!-- markdownlint-configure-file",
"# Comment",
"first-line-heading: false",
"-->",
""
].join("\n")
},
"configParsers": [ jsYaml.load ]
};
const actual = await markdownlint.promises.markdownlint(options);
t.is(actual.toString(), "", "Unexpected results.");
});
test("configParsersTOML", async(t) => {
t.plan(1);
// eslint-disable-next-line node/no-unsupported-features/es-syntax
const { "default": stripJsonComments } = await import("strip-json-comments");
const options = {
"strings": {
"content": [
"Text",
"",
"<!-- markdownlint-configure-file",
"# Comment",
"first-line-heading = false",
"-->",
""
].join("\n")
},
"configParsers": [
(content) => JSON.parse(stripJsonComments(content)),
require("toml").parse
]
};
const actual = await markdownlint.promises.markdownlint(options);
t.is(actual.toString(), "", "Unexpected results.");
});
test("getVersion", (t) => {
t.plan(1);
const actual = markdownlint.getVersion();