UNPKG

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
// @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. */