markdownlint-cli2
Version:
A fast, flexible, configuration-based command-line interface for linting Markdown/CommonMark files with the `markdownlint` library
1,260 lines (1,180 loc) • 44.5 kB
JavaScript
// @ts-check
// Imports
import fsNode from "node:fs";
import os from "node:os";
import pathDefault from "node:path";
const pathPosix = pathDefault.posix;
import { pathToFileURL } from "node:url";
import { globby } from "globby";
import jsonpointer from "jsonpointer";
import micromatch from "micromatch";
import { applyFixes, getVersion, resolveModule } from "markdownlint";
import { lint, extendConfig, readConfig } from "markdownlint/promise";
import { expandTildePath } from "markdownlint/helpers";
import appendToArray from "./append-to-array.mjs";
import { cli2SchemaKeys, libraryName, packageName, packageVersion } from "./constants.mjs";
import cloneOptions from "./clone-options.mjs";
import mergeOptions from "./merge-options.mjs";
import allParsers from "./parsers/parsers.mjs";
import jsoncParse from "./parsers/jsonc-parse.mjs";
import tomlParse from "./parsers/toml-parse.mjs";
import yamlParse from "./parsers/yaml-parse.mjs";
/* eslint-disable jsdoc/reject-any-type */
// Variables
const libraryVersion = getVersion();
const bannerMessage = `${packageName} v${packageVersion} (${libraryName} v${libraryVersion})`;
const dotOnlySubstitute = "*.{md,markdown}";
const utf8 = "utf8";
// No-op function
const noop = () => null;
// Negates a glob
const negateGlob = (/** @type {string} */ glob) => `!${glob}`;
// Reads and parses a JSONC file
const readJsonc = (/** @type {string} */ file, /** @type {FsLike} */ fs) => fs.promises.readFile(file, utf8).then(jsoncParse);
// Reads and parses a TOML file
const readToml = (/** @type {string} */ file, /** @type {FsLike} */ fs) => fs.promises.readFile(file, utf8).then(tomlParse);
// Reads and parses a YAML file
const readYaml = (/** @type {string} */ file, /** @type {FsLike} */ fs) => fs.promises.readFile(file, utf8).then(yamlParse);
// Pluralizes a noun (if necessary)
const pluralize = (/** @type {number} */ count, /** @type {string} */ noun) => `${count} ${noun}${count === 1 ? "" : (noun.endsWith("x") ? "es" : "s")}`;
// Throws a meaningful exception for an unusable configuration file
const throwForConfigurationFile = (/** @type {string} */ file, /** @type {Error | any} */ error) => {
throw new Error(
`Unable to use configuration file '${file}'; ${error?.message}`,
{ "cause": error }
);
};
// Return a POSIX path (even on Windows)
const posixPath = (/** @type {string} */ p) => p.split(pathDefault.sep).join(pathPosix.sep);
// Resolves module paths relative to the specified directory
const resolveModulePaths = (/** @type {string} */ dir, /** @type {string[]} */ modulePaths) => (
modulePaths.map((path) => pathDefault.resolve(dir, expandTildePath(path, os)))
);
// Import a module ID with a custom directory in the path
const importModule = async (/** @type {string[] | string} */ dirOrDirs, /** @type {any} */ id, /** @type {boolean} */ noImport) => {
if (typeof id !== "string") {
return id;
}
if (noImport) {
return null;
}
const dirs = Array.isArray(dirOrDirs) ? dirOrDirs : [ dirOrDirs ];
const expandId = expandTildePath(id, os);
const errors = [];
let moduleName = null;
try {
try {
moduleName = pathToFileURL(resolveModule(expandId, dirs));
} catch (error) {
errors.push(error);
moduleName =
(!pathDefault.isAbsolute(expandId) && URL.canParse(expandId))
? new URL(expandId)
: pathToFileURL(pathDefault.resolve(dirs[0], expandId));
}
// @ts-ignore
// eslint-disable-next-line no-inline-comments
const module = await import(/* webpackIgnore: true */ moduleName);
return module.default;
} catch (error) {
errors.push(error);
// eslint-disable-next-line preserve-caught-error
throw new AggregateError(
errors,
`Unable to import module '${id}'.`
);
}
};
// Import an array of modules by ID
const importModuleIds = (/** @type {string[]} */ dirs, /** @type {any[]} */ ids, /** @type {boolean} */ noImport) => (
Promise.all(
ids.map(
(id) => importModule(dirs, id, noImport)
)
).then((results) => results.filter(Boolean))
);
// Import an array of modules by ID (preserving parameters)
const importModuleIdsAndParams = (/** @type {string[]} */ dirs, /** @type {any[][]} */ idsAndParams, /** @type {boolean} */ noImport) => (
Promise.all(
idsAndParams.map(
(idAndParams) => importModule(dirs, idAndParams[0], noImport).
then((module) => module && [ module, ...idAndParams.slice(1) ])
)
).then((results) => results.filter(Boolean))
);
// Update a config object (in place) if it has an "extends" property
const extendConfigObject = async (/** @type {ExecutionContext} */ context, /** @type {Configuration | null | undefined} */ config, /** @type {string} */ configPath) => {
if (config?.extends) {
const { fs, parsers } = context;
const extended = await extendConfig(
config,
configPath,
parsers,
fs
);
for (const [ key, value ] of Object.entries(extended)) {
config[key] = value;
}
}
return config;
};
const extendOptionsObject = (/** @type {ExecutionContext} */ context, /** @type {Options | null | undefined} */ options, /** @type {string} */ configPath) => {
const configs = [ options?.config ];
appendToArray(configs, (options?.overrides || []).map((override) => override.config));
return Promise.all(configs.
map((config) => extendConfigObject(
context,
config,
configPath
))
);
};
// Read an options or config file in any format and return the object
const readOptionsOrConfig = async (/** @type {ExecutionContext} */ context, /** @type {string} */ configPath, /** @type {string | undefined} */ configPointer) => {
const { fs, noImport } = context;
const basename = pathPosix.basename(configPath);
const dirname = pathPosix.dirname(configPath);
let options = null;
let config = null;
let unknown = null;
try {
if (basename.endsWith(".markdownlint-cli2.jsonc")) {
options = await readJsonc(configPath, fs);
} else if (basename.endsWith(".markdownlint-cli2.yaml")) {
options = await readYaml(configPath, fs);
} else if (basename.endsWith(".markdownlint-cli2.cjs") || basename.endsWith(".markdownlint-cli2.mjs")) {
options = cloneOptions(await importModule(dirname, basename, noImport));
} else if (basename.endsWith(".markdownlint.jsonc") || basename.endsWith(".markdownlint.json")) {
config = await readJsonc(configPath, fs);
} else if (basename.endsWith(".markdownlint.yaml") || basename.endsWith(".markdownlint.yml")) {
config = await readYaml(configPath, fs);
} else if (basename.endsWith(".markdownlint.cjs") || basename.endsWith(".markdownlint.mjs")) {
config = cloneOptions(await importModule(dirname, basename, noImport));
} else if (basename.endsWith(".jsonc") || basename.endsWith(".json")) {
unknown = await readJsonc(configPath, fs);
} else if (basename.endsWith(".toml")) {
unknown = await readToml(configPath, fs);
} else if (basename.endsWith(".yaml") || basename.endsWith(".yml")) {
unknown = await readYaml(configPath, fs);
} else if (basename.endsWith(".cjs") || basename.endsWith(".mjs")) {
unknown = cloneOptions(await importModule(dirname, basename, noImport));
} else {
throw new Error(
"Configuration file should be one of the supported names " +
"(e.g., '.markdownlint-cli2.jsonc') or a prefix with a supported name " +
"(e.g., 'example.markdownlint-cli2.jsonc') or have a supported extension " +
"(e.g., jsonc, json, yaml, yml, cjs, mjs)."
);
}
} catch (error) {
throwForConfigurationFile(configPath, error);
}
if (configPointer) {
const objects = [ options, config, unknown ];
[ options, config, unknown ] = objects.map((obj) => obj && (jsonpointer.get(obj, configPointer) || {}));
}
if (unknown) {
const keys = Object.keys(unknown);
if (keys.some((key) => cli2SchemaKeys.has(key))) {
options = unknown;
} else {
config = unknown;
}
}
if (options) {
await extendOptionsObject(context, options, configPath);
return options;
}
await extendConfigObject(context, config, configPath);
return { config };
};
// Filter a list of files by glob(s)
const filterByGlobs = (/** @type {string} */ dir, /** @type {string[]} */ files, /** @type {string[]} */ globs) => (
micromatch(
files.map((file) => pathPosix.relative(dir, file)),
globs,
{ "dot": true }
).map((file) => pathPosix.join(dir, file))
);
// Process/normalize command-line arguments and return glob patterns
const processArgv = (/** @type {string[]} */ argv) => {
const globPatterns = argv.map(
(glob) => {
if (glob.startsWith(":")) {
return glob;
}
// Escape RegExp special characters recognized by fast-glob
// https://github.com/mrmlnc/fast-glob#advanced-syntax
const specialCharacters = /\\(?![$()*+?[\]^])/gu;
if (glob.startsWith("\\:")) {
return `\\:${glob.slice(2).replace(specialCharacters, "/")}`;
}
return (glob.startsWith("#") ? `!${glob.slice(1)}` : glob).
replace(specialCharacters, "/");
}
);
if ((globPatterns.length === 1) && (globPatterns[0] === ".")) {
// Substitute a more reasonable pattern
globPatterns[0] = dotOnlySubstitute;
}
return globPatterns;
};
// Show help if missing arguments
const showHelp = (/** @type {Logger} */ logMessage, /** @type {boolean} */ showBanner) => {
if (showBanner) {
logMessage(bannerMessage);
}
logMessage(`https://github.com/DavidAnson/markdownlint-cli2
Syntax: markdownlint-cli2 glob0 [glob1] [...] [globN] [--config file] [--configPointer pointer] [--fix] [--format] [--help] [--no-globs]
Glob expressions (from the globby library):
- * matches any number of characters, but not /
- ? matches a single character, but not /
- ** matches any number of characters, including /
- {} allows for a comma-separated list of "or" expressions
- ! or # at the beginning of a pattern negate the match
- : at the beginning identifies a literal file path
- - as a glob represents standard input (stdin)
Dot-only glob:
- The command "markdownlint-cli2 ." would lint every file in the current directory tree which is probably not intended
- Instead, it is mapped to "markdownlint-cli2 ${dotOnlySubstitute}" which lints all Markdown files in the current directory
- To lint every file in the current directory tree, the command "markdownlint-cli2 **" can be used instead
Optional parameters:
- --config specifies the path to a configuration file to define the base configuration
- --configPointer specifies a JSON Pointer to a configuration object within the --config file
- --fix updates files to resolve fixable issues (can be overridden in configuration)
- --format reads standard input (stdin), applies fixes, writes standard output (stdout)
- --help writes this message to the console and exits without doing anything else
- --no-globs ignores the "globs" property if present in the top-level options object
Configuration via:
- .markdownlint-cli2.jsonc
- .markdownlint-cli2.yaml
- .markdownlint-cli2.cjs or .markdownlint-cli2.mjs
- .markdownlint.jsonc or .markdownlint.json
- .markdownlint.yaml or .markdownlint.yml
- .markdownlint.cjs or .markdownlint.mjs
Cross-platform compatibility:
- UNIX and Windows shells expand globs according to different rules; quoting arguments is recommended
- Some Windows shells don't handle single-quoted (') arguments well; double-quote (") is recommended
- Shells that expand globs do not support negated patterns (!node_modules); quoting is required here
- Some UNIX shells parse exclamation (!) in double-quotes; hashtag (#) is recommended in these cases
- The path separator is forward slash (/) on all platforms; backslash (\\) is automatically converted
- On any platform, passing the parameter "--" causes all remaining parameters to be treated literally
The most compatible syntax for cross-platform support:
$ markdownlint-cli2 "**/*.md" "#node_modules"`
);
return 2;
};
// Helpers for getAndProcessDirInfo/handleFirstMatchingConfigurationFile
// Options inputs do not need special handling
const readJsoncWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs }) => readJsonc(file, fs);
const readYamlWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs }) => readYaml(file, fs);
const importOptionsModuleWrapper = async (/** @type {ConfigurationHandlerParams} */ { dir, file, noImport }) =>
cloneOptions(await importModule(dir, file, noImport));
// Configuration inputs need "extends" handled
const readConfigWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs, parsers }) => readConfig(file, parsers, fs);
const importConfigModuleWrapper = async (/** @type {ConfigurationHandlerParams} */ { context, dir, file, noImport }) =>
extendConfigObject(context, cloneOptions(await importModule(dir, file, noImport)), file);
/** @type {ConfigurationFileAndHandler[] } */
const optionsFiles = [
[ ".markdownlint-cli2.jsonc", readJsoncWrapper ],
[ ".markdownlint-cli2.yaml", readYamlWrapper ],
[ ".markdownlint-cli2.cjs", importOptionsModuleWrapper ],
[ ".markdownlint-cli2.mjs", importOptionsModuleWrapper ]
];
/** @type {ConfigurationFileAndHandler[] } */
const configurationFiles = [
[ ".markdownlint.jsonc", readConfigWrapper ],
[ ".markdownlint.json", readConfigWrapper ],
[ ".markdownlint.yaml", readConfigWrapper ],
[ ".markdownlint.yml", readConfigWrapper ],
[ ".markdownlint.cjs", importConfigModuleWrapper ],
[ ".markdownlint.mjs", importConfigModuleWrapper ]
];
/**
* Processes the first matching configuration file.
* @param {ExecutionContext} context Execution context.
* @param {ConfigurationFileAndHandler[]} fileAndHandlers List of configuration files and handlers.
* @param {string} dir Configuration file directory.
* @param {(file: string) => void} memoizeFile Function to memoize file name.
* @returns {Promise<any>} Configuration file content.
*/
const processFirstMatchingConfigurationFile = (context, fileAndHandlers, dir, memoizeFile) => {
const { fs, noImport, parsers } = context;
return Promise.allSettled(
fileAndHandlers.map(([ name, handler ]) => {
const file = pathPosix.join(dir, name);
return fs.promises.access(file).then(() => [ file, handler ]);
})
).
then((values) => {
/** @type {ConfigurationFileAndHandler} */
const [ file, handler ] = values.find((result) => (result.status === "fulfilled"))?.value || [ "[UNUSED]", noop ];
memoizeFile(file);
return handler({ context, dir, file, fs, noImport, parsers });
});
};
// Get (creating if necessary) and process a directory's info object
const getAndProcessDirInfo = (
/** @type {ExecutionContext} */ context,
/** @type {Task[]} */ tasks,
/** @type {DirToDirInfo} */ dirToDirInfo,
/** @type {string} */ dir,
/** @type {string | null} */ relativeDir
) => {
// Create dirInfo
let dirInfo = dirToDirInfo[dir];
if (!dirInfo) {
dirInfo = {
dir,
relativeDir,
"parent": null,
"files": [],
"markdownlintConfig": null,
"markdownlintOptions": null
};
dirToDirInfo[dir] = dirInfo;
let cli2File = "[UNKNOWN]";
tasks.push(
// Load markdownlint-cli2 object(s)
processFirstMatchingConfigurationFile(context, optionsFiles, dir, (file) => { cli2File = file; }).
then((/** @type {Options | null} */ options) => {
dirInfo.markdownlintOptions = options;
return extendOptionsObject(context, options, cli2File);
}).
catch((/** @type {Error} */ error) => {
throwForConfigurationFile(cli2File, error);
}),
// Load markdownlint object(s)
processFirstMatchingConfigurationFile(context, configurationFiles, dir, noop).
then((/** @type {Configuration | null} */ config) => {
dirInfo.markdownlintConfig = config;
})
);
}
// Return dirInfo
return dirInfo;
};
// Get base markdownlint-cli2 options object
const getBaseOptions = async (
/** @type {ExecutionContext} */ context,
/** @type {string | null} */ relativeDir,
/** @type {string[]} */ globPatterns,
/** @type {Options} */ options,
/** @type {boolean} */ fixDefault,
/** @type {boolean} */ noGlobs
) => {
const { baseDir } = context;
/** @type {Task[]} */
const tasks = [];
/** @type {DirToDirInfo} */
const dirToDirInfo = {};
getAndProcessDirInfo(
context,
tasks,
dirToDirInfo,
baseDir,
relativeDir
);
await Promise.all(tasks);
// eslint-disable-next-line no-multi-assign
const baseMarkdownlintOptions = dirToDirInfo[baseDir].markdownlintOptions =
mergeOptions(
mergeOptions(
{ "fix": fixDefault },
options
),
dirToDirInfo[baseDir].markdownlintOptions
);
if (!noGlobs) {
// Append any globs specified in markdownlint-cli2 configuration
const globs = baseMarkdownlintOptions.globs || [];
appendToArray(globPatterns, globs);
}
// Pass base ignore globs as globby patterns (best performance)
const ignorePatterns =
// eslint-disable-next-line unicorn/no-array-callback-reference
(baseMarkdownlintOptions.ignores || []).map(negateGlob);
appendToArray(globPatterns, ignorePatterns);
return {
baseMarkdownlintOptions,
dirToDirInfo
};
};
// Enumerate files from globs and build directory infos
const enumerateFiles = async (
/** @type {ExecutionContext} */ context,
/** @type {string[]} */ globPatterns,
/** @type {DirToDirInfo} */ dirToDirInfo,
/** @type {boolean} */ gitignore,
/** @type {string | undefined} */ ignoreFiles
) => {
const { baseDir, baseDirSystem, fs } = context;
/** @type {Task[]} */
const tasks = [];
/** @type {import("globby").Options} */
const globbyOptions = {
"absolute": true,
"cwd": baseDir,
"dot": true,
"expandNegationOnlyPatterns": false,
gitignore,
ignoreFiles,
"suppressErrors": true,
fs
};
// Special-case literal files
/** @type {string[]} */
const literalFiles = [];
const filteredGlobPatterns = globPatterns.filter(
(globPattern) => {
if (globPattern.startsWith(":")) {
literalFiles.push(
posixPath(pathDefault.resolve(baseDirSystem, globPattern.slice(1)))
);
return false;
}
return true;
}
).map((globPattern) => globPattern.replace(/^\\:/u, ":"));
const baseMarkdownlintOptions = dirToDirInfo[baseDir].markdownlintOptions;
const globsForIgnore =
(baseMarkdownlintOptions?.globs || []).
filter((glob) => glob.startsWith("!"));
const filteredLiteralFiles =
((literalFiles.length > 0) && (globsForIgnore.length > 0))
? filterByGlobs(baseDir, literalFiles, globsForIgnore)
: literalFiles;
// Process glob patterns
const files = [
...await globby(filteredGlobPatterns, globbyOptions),
...filteredLiteralFiles
];
for (const file of files) {
const dir = pathPosix.dirname(file);
const dirInfo = getAndProcessDirInfo(
context,
tasks,
dirToDirInfo,
dir,
null
);
dirInfo.files.push(file);
}
await Promise.all(tasks);
};
// Enumerate (possibly missing) parent directories and update directory infos
const enumerateParents = async (
/** @type {ExecutionContext} */ context,
/** @type {DirToDirInfo} */ dirToDirInfo
) => {
const { baseDir } = context;
/** @type {Task[]} */
const tasks = [];
// Create a lookup of baseDir and parents
/** @type {Record<string, boolean>} */
const baseDirParents = {};
let baseDirParent = baseDir;
do {
baseDirParents[baseDirParent] = true;
baseDirParent = pathPosix.dirname(baseDirParent);
// eslint-disable-next-line unicorn/no-computed-property-existence-check
} while (!baseDirParents[baseDirParent]);
// Visit parents of each dirInfo
for (let lastDirInfo of Object.values(dirToDirInfo)) {
let { dir } = lastDirInfo;
let lastDir = dir;
while (
// eslint-disable-next-line unicorn/no-computed-property-existence-check
!baseDirParents[dir] &&
(dir = pathPosix.dirname(dir)) &&
// eslint-disable-next-line unicorn/prefer-simple-condition-first
(dir !== lastDir)
) {
lastDir = dir;
const dirInfo =
getAndProcessDirInfo(
context,
tasks,
dirToDirInfo,
dir,
null
);
lastDirInfo.parent = dirInfo;
lastDirInfo = dirInfo;
}
// If dir not under baseDir, inject it as parent for configuration
if (dir !== baseDir) {
dirToDirInfo[dir].parent = dirToDirInfo[baseDir];
}
}
await Promise.all(tasks);
};
// Create directory info objects by enumerating file globs
const createDirInfos = async (
/** @type {ExecutionContext} */ context,
/** @type {string[]} */ globPatterns,
/** @type {DirToDirInfo} */ dirToDirInfo,
/** @type {Options | undefined} */ optionsOverride,
/** @type {boolean} */ gitignore,
/** @type {string | undefined} */ ignoreFiles
) => {
await enumerateFiles(
context,
globPatterns,
dirToDirInfo,
gitignore,
ignoreFiles
);
await enumerateParents(
context,
dirToDirInfo
);
// Merge file lists with identical configuration
// (Sort so parents are handled before children)
const dirs = Object.keys(dirToDirInfo);
dirs.sort((a, b) => b.length - a.length);
/** @type {DirInfo[]} */
const dirInfos = [];
const tasks = [];
for (const dir of dirs) {
const dirInfo = dirToDirInfo[dir];
if (dirInfo.parent && !dirInfo.markdownlintConfig && !dirInfo.markdownlintOptions) {
if (dirInfo.parent) {
appendToArray(dirInfo.parent.files, dirInfo.files);
}
delete dirToDirInfo[dir];
} else {
const { noImport } = context;
const { markdownlintOptions, relativeDir } = dirInfo;
const effectiveDir = relativeDir || dir;
const effectiveModulePaths = resolveModulePaths(
effectiveDir,
(markdownlintOptions && markdownlintOptions.modulePaths) || []
);
if (markdownlintOptions && markdownlintOptions.customRules) {
tasks.push(
importModuleIds(
[ effectiveDir, ...effectiveModulePaths ],
// @ts-ignore
markdownlintOptions.customRules,
noImport
).then((customRules) => {
// Expand nested arrays (for packages that export multiple rules)
markdownlintOptions.customRules = customRules.flat();
})
);
}
if (markdownlintOptions && markdownlintOptions.markdownItPlugins) {
tasks.push(
importModuleIdsAndParams(
[ effectiveDir, ...effectiveModulePaths ],
markdownlintOptions.markdownItPlugins,
noImport
).then((markdownItPlugins) => {
markdownlintOptions.markdownItPlugins = markdownItPlugins;
})
);
}
dirInfos.push(dirInfo);
}
}
await Promise.all(tasks);
for (const dirInfo of dirInfos) {
// eslint-disable-next-line unicorn/no-computed-property-existence-check
while (dirInfo.parent && !dirToDirInfo[dirInfo.parent.dir]) {
dirInfo.parent = dirInfo.parent.parent;
}
}
// Verify dirInfos is simplified
// if (
// dirInfos.filter(
// (di) => di.parent && !dirInfos.includes(di.parent)
// ).length > 0
// ) {
// throw new Error("Extra parent");
// }
// if (
// dirInfos.filter(
// (di) => !di.parent && (di.dir !== context.baseDir)
// ).length > 0
// ) {
// throw new Error("Missing parent");
// }
// if (
// dirInfos.filter(
// (di) => di.parent &&
// !((di.markdownlintConfig ? 1 : 0) ^ (di.markdownlintOptions ? 1 : 0))
// ).length > 0
// ) {
// throw new Error("Missing object");
// }
// if (dirInfos.filter((di) => di.dir === "/").length > 0) {
// throw new Error("Includes root");
// }
// Merge configuration by inheritance
for (const dirInfo of dirInfos) {
let { markdownlintConfig, markdownlintOptions } = dirInfo;
/** @type {DirInfo | null} */
let parent = dirInfo;
// eslint-disable-next-line prefer-destructuring
while ((parent = parent.parent)) {
if (parent.markdownlintOptions) {
markdownlintOptions = mergeOptions(
parent.markdownlintOptions,
markdownlintOptions
);
}
if (
!markdownlintConfig &&
parent.markdownlintConfig &&
!markdownlintOptions?.config
) {
// eslint-disable-next-line prefer-destructuring
markdownlintConfig = parent.markdownlintConfig;
}
}
dirInfo.markdownlintOptions = mergeOptions(
markdownlintOptions,
optionsOverride
);
dirInfo.markdownlintConfig = markdownlintConfig;
}
// Apply configuration overrides
/** @type {DirInfo[]} */
const overrideDirInfos = [];
for (const dirInfo of dirInfos) {
const overrides = dirInfo.markdownlintOptions?.overrides || [];
for (const override of overrides) {
const { filter, config, combine } = override;
if (filter && config && (filter.length > 0) && ((combine === "merge") || (combine === "replace"))) {
const filteredFiles = filterByGlobs(dirInfo.dir, dirInfo.files, filter);
if (filteredFiles.length > 0) {
dirInfo.files = dirInfo.files.filter((file) => !filteredFiles.includes(file));
const overrideConfig = (combine === "merge") ? { ...dirInfo.markdownlintOptions?.config, ...config } : config;
const overrideDirInfo = {
...dirInfo,
"files": filteredFiles,
"markdownlintOptions": { ...dirInfo.markdownlintOptions, "config": overrideConfig }
};
overrideDirInfos.push(overrideDirInfo);
}
}
}
}
appendToArray(dirInfos, overrideDirInfos);
// Return results
return dirInfos;
};
// Lint files in groups by shared configuration
const lintFiles = (
/** @type {ExecutionContext} */ context,
/** @type {DirInfo[]} */ dirInfos,
/** @type {Record<string, string>} */ fileContents,
/** @type {FormattingContext} */ formattingContext
) => {
const { fs, parsers } = context;
/** @type {Promise<LintTaskResult>[]} */
const tasks = [];
// For each dirInfo
for (const dirInfo of dirInfos) {
const { dir, files, markdownlintConfig, markdownlintOptions } = dirInfo;
// Filter file/string inputs to only those in the dirInfo
let filesAfterIgnores = files;
if (
markdownlintOptions &&
markdownlintOptions.ignores &&
(markdownlintOptions.ignores.length > 0)
) {
// eslint-disable-next-line unicorn/no-array-callback-reference
const ignores = markdownlintOptions.ignores.map(negateGlob);
filesAfterIgnores = filterByGlobs(dir, files, ignores);
}
const filteredFiles = filesAfterIgnores.filter(
(file) => fileContents[file] === undefined
);
/** @type {Record<string, string>} */
const filteredStrings = {};
for (const file of filesAfterIgnores) {
if (fileContents[file] !== undefined) {
filteredStrings[file] = fileContents[file];
}
}
// Create markdown-it factory
const markdownItFactory = async () => {
// eslint-disable-next-line no-inline-comments
const module = await import(/* webpackMode: "eager" */ "markdown-it");
const markdownIt = module.default({ "html": true });
const plugins = markdownlintOptions?.markdownItPlugins || [];
for (const plugin of plugins) {
// @ts-ignore
markdownIt.use(...plugin);
}
return markdownIt;
};
// Create markdownlint options object
/** @type {import("markdownlint").Options} */
const options = {
"files": filteredFiles,
"strings": filteredStrings,
"config": markdownlintConfig || markdownlintOptions?.config,
"configParsers": parsers,
// @ts-ignore
"customRules": markdownlintOptions.customRules,
"frontMatter": markdownlintOptions?.frontMatter
? new RegExp(markdownlintOptions?.frontMatter, "u")
: undefined,
"handleRuleFailures": true,
markdownItFactory,
"noInlineConfig": Boolean(markdownlintOptions?.noInlineConfig),
fs
};
// Invoke markdownlint
/** @type {Promise<LintTaskResult>} */
let task = lint(options).then((results) => ({
results,
"filesAttempted": 0,
"issuesAttempted": 0
}));
if (formattingContext.formatting) {
// Apply fixes to stdin input
task = task.then((taskResult) => {
const { results } = taskResult;
const [ [ id, original ] ] = Object.entries(filteredStrings);
const errorInfos = results[id];
formattingContext.formatted = applyFixes(original, errorInfos);
return {
...taskResult,
"results": {}
};
});
} else if (markdownlintOptions?.fix) {
// For any fixable errors, read file, apply fixes, write it back, and re-lint
task = task.then((taskResult) => {
const { results } = taskResult;
let issuesToFix = 0;
/** @type {string[]} */
const filesToFix = [];
options.files = filesToFix;
const subTasks = [];
const errorFiles = Object.keys(results).
filter((result) => filteredFiles.includes(result));
for (const fileName of errorFiles) {
const errorInfos = results[fileName].
filter((errorInfo) => errorInfo.fixInfo);
if (errorInfos.length > 0) {
issuesToFix += errorInfos.length;
filesToFix.push(fileName);
subTasks.push(fs.promises.readFile(fileName, utf8).
then((/** @type {string} */ original) => {
const fixed = applyFixes(original, errorInfos);
return fs.promises.writeFile(fileName, fixed, utf8);
})
);
}
}
return Promise.all(subTasks).
then(() => lint(options)).
then((fixResults) => ({
"results": {
...results,
...fixResults
},
"filesAttempted": filesToFix.length,
"issuesAttempted": issuesToFix
}));
});
}
// Queue tasks for this dirInfo
tasks.push(task);
}
// Return result of all tasks
return Promise.all(tasks);
};
// Create flat list of results
const flattenTaskResults = (/** @type {string} */ baseDir, /** @type {LintTaskResult[]} */ taskResults) => {
/** @type {LintResult[]} */
const lintResults = [];
let totalIssuesReported = 0;
let totalFilesReported = 0;
let totalIssuesAttempted = 0;
let totalFilesAttempted = 0;
/** @type {Map<LintResult, number>} */
const resultToCounter = new Map();
for (const taskResult of taskResults) {
const { results, filesAttempted, issuesAttempted } = taskResult;
const resultsEntries = Object.entries(results).
filter(([ , errorInfos ]) => errorInfos.length > 0);
for (const [ fileName, errorInfos ] of resultsEntries) {
for (const errorInfo of errorInfos) {
const fileNameRelative = pathPosix.relative(baseDir, fileName);
const result = {
"fileName": fileNameRelative,
...errorInfo
};
lintResults.push(result);
resultToCounter.set(result, totalIssuesReported);
totalIssuesReported++;
}
}
totalFilesReported += resultsEntries.length;
totalIssuesAttempted += issuesAttempted;
totalFilesAttempted += filesAttempted;
}
lintResults.sort((a, b) => (
a.fileName.localeCompare(b.fileName) ||
(a.lineNumber - b.lineNumber) ||
a.ruleNames[0].localeCompare(b.ruleNames[0]) ||
// @ts-ignore
(resultToCounter.get(a) - resultToCounter.get(b))
));
return {
lintResults,
"issuesReported": totalIssuesReported,
"filesReported": totalFilesReported,
"issuesAttempted": totalIssuesAttempted,
"filesAttempted": totalFilesAttempted
};
};
// Output summary via formatters
const outputResults = async (
/** @type {string} */ baseDir,
/** @type {string | null} */ relativeDir,
/** @type {LintResult[]} */ results,
/** @type {OutputFormatterConfiguration[] | undefined} */ outputFormatters,
/** @type {string[]} */ modulePaths,
/** @type {Logger} */ logMessage,
/** @type {Logger} */ logError,
/** @type {boolean} */ noImport
) => {
// eslint-disable-next-line unicorn/prefer-early-return
if (outputFormatters || (results.length > 0)) {
/** @type {OutputFormatterOptions} */
const formatterOptions = {
"directory": baseDir,
results,
logMessage,
logError
};
const dir = relativeDir || baseDir;
const dirs = [ dir, ...modulePaths ];
const formattersAndParams = outputFormatters
? await importModuleIdsAndParams(dirs, outputFormatters, noImport)
// eslint-disable-next-line no-inline-comments, unicorn/no-await-expression-member
: [ [ (await import(/* webpackMode: "eager" */ "markdownlint-cli2-formatter-default")).default ] ];
await Promise.all(formattersAndParams.map((formatterAndParams) => {
const [ formatter, ...formatterParams ] = formatterAndParams;
return formatter(formatterOptions, ...formatterParams);
}));
}
};
// Main function
export const main = async (/** @type {Parameters} */ params) => {
// Capture parameters
const {
directory,
argv,
optionsDefault,
optionsOverride,
fileContents,
allowStdin
} = params;
let {
noGlobs,
nonFileContents
} = params;
const logMessage = params.logMessage || noop;
const logError = params.logError || noop;
const fs = params.fs || fsNode;
const noImport = Boolean(params.noImport);
const baseDirSystem =
(directory && pathDefault.resolve(directory)) ||
process.cwd();
const baseDir = posixPath(baseDirSystem);
/** @type {FormattingContext} */
const formattingContext = {};
// Merge and process args/argv
let fixDefault = false;
/** @type {undefined | null | string} */
// eslint-disable-next-line unicorn/no-useless-undefined
let configPath = undefined;
/** @type {undefined | null | string} */
// eslint-disable-next-line unicorn/no-useless-undefined
let configPointer = undefined;
let useStdin = false;
let sawDashDash = false;
let shouldShowHelp = false;
const argvFiltered = (argv || []).filter((arg) => {
if (sawDashDash) {
return true;
// eslint-disable-next-line unicorn/no-useless-else
} else if (configPath === null) {
configPath = arg;
} else if (configPointer === null) {
configPointer = arg;
} else if ((arg === "-") && allowStdin) {
useStdin = true;
// eslint-disable-next-line unicorn/prefer-switch
} else if (arg === "--") {
sawDashDash = true;
} else if (arg === "--config") {
configPath = null;
} else if (arg === "--configPointer") {
configPointer = null;
} else if (arg === "--fix") {
fixDefault = true;
} else if (arg === "--format") {
formattingContext.formatting = true;
useStdin = true;
} else if (arg === "--help") {
shouldShowHelp = true;
} else if (arg === "--no-globs") {
noGlobs = true;
} else {
return true;
}
return false;
});
if (shouldShowHelp) {
return showHelp(logMessage, true);
}
/** @type {ExecutionContext} */
const context = { baseDir, baseDirSystem, fs, noImport, "parsers": allParsers };
// Read argv configuration file (if relevant and present)
let optionsArgv = null;
let relativeDir = null;
let globPatterns = null;
let baseOptions = null;
try {
if (configPath) {
const resolvedConfigPath = posixPath(pathDefault.resolve(baseDirSystem, configPath));
optionsArgv = await readOptionsOrConfig(context, resolvedConfigPath, configPointer);
relativeDir = pathPosix.dirname(resolvedConfigPath);
}
// Process arguments and get base options
globPatterns = processArgv(argvFiltered);
baseOptions = await getBaseOptions(
context,
relativeDir,
globPatterns,
optionsArgv || optionsDefault,
fixDefault,
Boolean(noGlobs)
);
} finally {
if (!baseOptions?.baseMarkdownlintOptions.noBanner && !formattingContext.formatting) {
logMessage(bannerMessage);
}
}
if (
(configPath === null) ||
(!useStdin && !nonFileContents && (globPatterns.length === 0))
) {
return showHelp(logMessage, false);
}
// Add stdin as a non-file input if necessary
if (useStdin) {
const key = pathPosix.join(baseDir, "stdin");
const { text } = await import("node:stream/consumers");
nonFileContents = {
...nonFileContents,
[key]: await text(process.stdin)
};
}
// Include any file overrides or non-file content
/** @type {Record<string, string>} */
const resolvedFileContents = {};
for (const file in fileContents) {
const resolvedFile = posixPath(pathDefault.resolve(baseDirSystem, file));
resolvedFileContents[resolvedFile] = fileContents[file];
}
for (const nonFile in nonFileContents) {
resolvedFileContents[nonFile] = nonFileContents[nonFile];
}
const { baseMarkdownlintOptions, dirToDirInfo } = baseOptions;
appendToArray(
dirToDirInfo[baseDir].files,
Object.keys(nonFileContents || {})
);
// Output finding status
const showProgress = !baseMarkdownlintOptions.noProgress && !formattingContext.formatting;
if (showProgress) {
logMessage(`Finding: ${globPatterns.join(" ")}`);
}
// Create linting tasks
const gitignore = (baseMarkdownlintOptions.gitignore === true);
const ignoreFiles = (typeof baseMarkdownlintOptions.gitignore === "string")
? baseMarkdownlintOptions.gitignore
: undefined;
const dirInfos =
await createDirInfos(
context,
globPatterns,
dirToDirInfo,
optionsOverride,
gitignore,
ignoreFiles
);
// Output linting status
if (showProgress) {
const fileNames = dirInfos.flatMap((dirInfo) => {
const { files } = dirInfo;
return files.map((file) => pathPosix.relative(baseDir, file));
});
const fileCount = fileNames.length;
if (baseMarkdownlintOptions.showFound) {
fileNames.push("");
// eslint-disable-next-line unicorn/require-array-sort-compare
fileNames.sort();
logMessage(`Found:${fileNames.join("\n ")}`);
}
logMessage(`Linting: ${pluralize(fileCount, "file")}`);
}
// Lint files
const taskResults = await lintFiles(context, dirInfos, resolvedFileContents, formattingContext);
// Output summary
const { lintResults, issuesReported, filesReported, filesAttempted, issuesAttempted } = flattenTaskResults(baseDir, taskResults);
if (showProgress) {
if (issuesAttempted > 0) {
logMessage(`Attempted: ${pluralize(issuesAttempted, "fix")} in ${pluralize(filesAttempted, "file")}`);
}
logMessage(`Summary: ${pluralize(issuesReported, "issue")} in ${pluralize(filesReported, "file")}`);
}
if (formattingContext.formatting) {
const { pipeline } = await import("node:stream/promises");
await pipeline(
String(formattingContext.formatted),
process.stdout
);
} else {
const outputFormatters =
(optionsOverride && optionsOverride.outputFormatters) ||
baseMarkdownlintOptions.outputFormatters;
const modulePaths = resolveModulePaths(
baseDir,
baseMarkdownlintOptions.modulePaths || []
);
await outputResults(
baseDir,
relativeDir,
lintResults,
outputFormatters,
modulePaths,
logMessage,
logError,
noImport
);
}
// Return result
const errorsPresent = taskResults.flatMap(
(taskResult) => Object.values(taskResult.results).flatMap(
(lintErrors) => lintErrors.filter(
(lintError) => lintError.severity !== "warning"
)
)
).length > 0;
return errorsPresent ? 1 : 0;
};
/** @typedef {any} FsLike */
/** @typedef {Promise<any>} Task */
/**
* @typedef ExecutionContext
* @property {string} baseDir Base directory (POSIX).
* @property {string} baseDirSystem Base directory (non-POSIX).
* @property {FsLike} fs File system object.
* @property {boolean} noImport No import.
* @property {ConfigurationParser[]} parsers Configuration file parsers.
*/
/**
* @typedef Parameters
* @property {boolean} [allowStdin] Allow stdin.
* @property {string[]} [argv] Arguments.
* @property {string} [directory] Directory.
* @property {Record<string, string>} [fileContents] File contents.
* @property {FsLike} [fs] File system object.
* @property {Logger} [logError] Log error.
* @property {Logger} [logMessage] Log message.
* @property {boolean} [noGlobs] No globs.
* @property {boolean} [noImport] No import.
* @property {Record<string, string>} [nonFileContents] Non-file contents.
* @property {Options} [optionsDefault] Options default.
* @property {Options} [optionsOverride] Options override.
*/
/** @typedef {import("markdownlint").Configuration} Configuration */
/** @typedef {import("markdownlint").ConfigurationParser} ConfigurationParser */
/**
* @typedef ConfigurationHandlerParams
* @property {ExecutionContext} context Execution context.
* @property {string} dir Configuration file directory.
* @property {string} file Configuration file.
* @property {FsLike} fs File system object.
* @property {ConfigurationParser[]} parsers Configuration file parsers.
* @property {boolean} noImport No import.
*/
/** @typedef {[ string, (params: ConfigurationHandlerParams) => Promise<any> ] } ConfigurationFileAndHandler */
/**
* @typedef Override
* @property {string[]} filter Globs to filter.
* @property {Configuration} config Configuration object.
* @property {"merge"|"replace"} combine Combine strategy.
*/
/**
* @typedef DirInfo
* @property {string} dir Directory.
* @property {string | null} relativeDir Relative directory.
* @property {DirInfo | null} parent Parent.
* @property {string[]} files Files.
* @property {Configuration | null} markdownlintConfig Configuration.
* @property {Options | null} markdownlintOptions Options.
*/
/** @typedef {Record<string, DirInfo>} DirToDirInfo */
/** @typedef {[string]} MarkdownItPluginConfiguration */
/** @typedef {[OutputFormatter, ...any]} OutputFormatterConfiguration */
/** @typedef {import("markdownlint").Rule} Rule */
/** @typedef {{ "results": LintResults, "filesAttempted": number, "issuesAttempted": number }} LintTaskResult */
/**
* @typedef Options
* @property {Configuration} [config] Configuration object.
* @property {Override[]} [overrides] Configuration overrides.
* @property {Rule[] | string[]} [customRules] Custom rules.
* @property {boolean} [fix] Fix supported violations.
* @property {string} [frontMatter] Front matter regular expression.
* @property {boolean | string} [gitignore] Switch or glob for .gitignore.
* @property {string[]} [globs] Globs to lint.
* @property {string[]} [ignores] Globs to ignore.
* @property {MarkdownItPluginConfiguration[]} [markdownItPlugins] Markdown-it plugins.
* @property {string[]} [modulePaths] Additional module paths.
* @property {boolean} [noBanner] Disable banner display.
* @property {boolean} [noInlineConfig] Disable inline config.
* @property {boolean} [noProgress] Disable progress display.
* @property {OutputFormatterConfiguration[]} [outputFormatters] Output formatters.
* @property {boolean} [showFound] Show found files.
*/
/**
* @typedef LintContext
* @property {string} fileName File name.
*/
/** @typedef {import("markdownlint").LintError & LintContext} LintResult */
/** @typedef {import("markdownlint").LintResults} LintResults */
/**
* @typedef FormattingContext
* @property {boolean} [formatting] True iff formatting.
* @property {string} [formatted] Formatted content.
*/
/**
* @callback Logger
* @param {string} msg Message.
* @returns {void}
*/
/**
* @callback OutputFormatter
* @param {OutputFormatterOptions} options
* @param {...any} parameters
* @returns {Promise<void> | void}
*/
/**
* @typedef {object} OutputFormatterOptions
* @property {string} directory Base directory.
* @property {LintResult[]} results Lint results.
* @property {Logger} logMessage Message logger.
* @property {Logger} logError Error logger.
*/