1166 lines
39 KiB
JavaScript
Executable File
1166 lines
39 KiB
JavaScript
Executable File
// @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 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);
|
|
|
|
// 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;
|
|
} else 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))
|
|
);
|
|
|
|
// Extend a config object if it has 'extends' property
|
|
const getExtendedConfig = (/** @type {ExecutionContext} */ context, /** @type {Configuration} */ config, /** @type {string} */ configPath) => {
|
|
if (config.extends) {
|
|
const { fs, parsers } = context;
|
|
return extendConfig(
|
|
config,
|
|
configPath,
|
|
parsers,
|
|
fs
|
|
);
|
|
}
|
|
|
|
return Promise.resolve(config);
|
|
};
|
|
|
|
// 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 = 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 = 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 = 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) {
|
|
if (options.config) {
|
|
options.config = await getExtendedConfig(context, options.config, configPath);
|
|
}
|
|
return options;
|
|
}
|
|
config = await getExtendedConfig(context, config, configPath);
|
|
return { config };
|
|
};
|
|
|
|
// Filter a list of files to ignore by glob
|
|
const removeIgnoredFiles = (/** @type {string} */ dir, /** @type {string[]} */ files, /** @type {string[]} */ ignores) => (
|
|
micromatch(
|
|
files.map((file) => pathPosix.relative(dir, file)),
|
|
ignores,
|
|
{ "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
|
|
const readJsoncWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs }) => readJsonc(file, fs);
|
|
const readYamlWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs }) => readYaml(file, fs);
|
|
const readConfigWrapper = (/** @type {ConfigurationHandlerParams} */ { file, fs, parsers }) => readConfig(file, parsers, fs);
|
|
const importModuleWrapper = (/** @type {ConfigurationHandlerParams} */ { dir, file, noImport }) => importModule(dir, file, noImport);
|
|
|
|
/** @type {ConfigurationFileAndHandler[] } */
|
|
const optionsFiles = [
|
|
[ ".markdownlint-cli2.jsonc", readJsoncWrapper ],
|
|
[ ".markdownlint-cli2.yaml", readYamlWrapper ],
|
|
[ ".markdownlint-cli2.cjs", importModuleWrapper ],
|
|
[ ".markdownlint-cli2.mjs", importModuleWrapper ]
|
|
];
|
|
|
|
/** @type {ConfigurationFileAndHandler[] } */
|
|
const configurationFiles = [
|
|
[ ".markdownlint.jsonc", readConfigWrapper ],
|
|
[ ".markdownlint.json", readConfigWrapper ],
|
|
[ ".markdownlint.yaml", readConfigWrapper ],
|
|
[ ".markdownlint.yml", readConfigWrapper ],
|
|
[ ".markdownlint.cjs", importModuleWrapper ],
|
|
[ ".markdownlint.mjs", importModuleWrapper ]
|
|
];
|
|
|
|
/**
|
|
* 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({ 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 options &&
|
|
options.config &&
|
|
getExtendedConfig(
|
|
context,
|
|
options.config,
|
|
// Just need to identify the right directory
|
|
pathPosix.join(dir, utf8)
|
|
).
|
|
then((config) => {
|
|
options.config = config;
|
|
});
|
|
}).
|
|
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))
|
|
? removeIgnoredFiles(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);
|
|
} while (!baseDirParents[baseDirParent]);
|
|
|
|
// Visit parents of each dirInfo
|
|
for (let lastDirInfo of Object.values(dirToDirInfo)) {
|
|
let { dir } = lastDirInfo;
|
|
let lastDir = dir;
|
|
while (
|
|
!baseDirParents[dir] &&
|
|
(dir = pathPosix.dirname(dir)) &&
|
|
(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
|
|
const dirs = Object.keys(dirToDirInfo);
|
|
dirs.sort((a, b) => b.length - a.length);
|
|
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) {
|
|
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 !== 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;
|
|
}
|
|
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;
|
|
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 = removeIgnoredFiles(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
|
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
|
const markdownItFactory = async () => {
|
|
// eslint-disable-next-line no-inline-comments
|
|
const module = await import(/* webpackMode: "eager" */ "markdown-it");
|
|
const markdownIt = module.default({ "html": true });
|
|
for (const plugin of (markdownlintOptions?.markdownItPlugins || [])) {
|
|
// @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
|
|
let task = lint(options);
|
|
if (formattingContext.formatting) {
|
|
// Apply fixes to stdin input
|
|
task = task.then((results) => {
|
|
const [ [ id, original ] ] = Object.entries(filteredStrings);
|
|
const errorInfos = results[id];
|
|
formattingContext.formatted = applyFixes(original, errorInfos);
|
|
return {};
|
|
});
|
|
} else if (markdownlintOptions?.fix) {
|
|
// For any fixable errors, read file, apply fixes, write it back, and re-lint
|
|
task = task.then((results) => {
|
|
options.files = [];
|
|
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) {
|
|
delete results[fileName];
|
|
options.files.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,
|
|
...fixResults
|
|
}));
|
|
});
|
|
}
|
|
// Queue tasks for this dirInfo
|
|
tasks.push(task);
|
|
}
|
|
// Return result of all tasks
|
|
return Promise.all(tasks);
|
|
};
|
|
|
|
// Create list of results
|
|
const createResults = (/** @type {string} */ baseDir, /** @type {LintResults[]} */ taskResults) => {
|
|
/** @type {LintResult[]} */
|
|
const results = [];
|
|
/** @type {Map<LintResult, number>} */
|
|
const resultToCounter = new Map();
|
|
let counter = 0;
|
|
for (const taskResult of taskResults) {
|
|
for (const [ fileName, errorInfos ] of Object.entries(taskResult)) {
|
|
for (const errorInfo of errorInfos) {
|
|
const fileNameRelative = pathPosix.relative(baseDir, fileName);
|
|
const result = {
|
|
"fileName": fileNameRelative,
|
|
...errorInfo
|
|
};
|
|
results.push(result);
|
|
resultToCounter.set(result, counter);
|
|
counter++;
|
|
}
|
|
}
|
|
}
|
|
results.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 results;
|
|
};
|
|
|
|
// 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
|
|
) => {
|
|
if ((results.length > 0) || outputFormatters) {
|
|
/** @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;
|
|
} 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 (
|
|
((globPatterns.length === 0) && !useStdin && !nonFileContents) ||
|
|
(configPath === null)
|
|
) {
|
|
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("");
|
|
fileNames.sort();
|
|
logMessage(`Found:${fileNames.join("\n ")}`);
|
|
}
|
|
logMessage(`Linting: ${fileCount} file(s)`);
|
|
}
|
|
// Lint files
|
|
const lintResults = await lintFiles(context, dirInfos, resolvedFileContents, formattingContext);
|
|
// Output summary
|
|
const results = createResults(baseDir, lintResults);
|
|
if (showProgress) {
|
|
logMessage(`Summary: ${results.length} error(s)`);
|
|
}
|
|
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,
|
|
results,
|
|
outputFormatters,
|
|
modulePaths,
|
|
logMessage,
|
|
logError,
|
|
Boolean(noImport)
|
|
);
|
|
}
|
|
// Return result
|
|
const errorsPresent = lintResults.flatMap(
|
|
(lintResult) => Object.values(lintResult).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 {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 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 Options
|
|
* @property {Configuration} [config] Config.
|
|
* @property {Rule[] | string[]} [customRules] Custom rules.
|
|
* @property {boolean} [fix] Fix.
|
|
* @property {string} [frontMatter] Front matter.
|
|
* @property {boolean | string} [gitignore] Git ignore.
|
|
* @property {string[]} [globs] Globs.
|
|
* @property {string[]} [ignores] Ignores.
|
|
* @property {MarkdownItPluginConfiguration[]} [markdownItPlugins] Markdown-it plugins.
|
|
* @property {string[]} [modulePaths] Module paths.
|
|
* @property {boolean} [noBanner] No banner.
|
|
* @property {boolean} [noInlineConfig] No inline config.
|
|
* @property {boolean} [noProgress] No progress.
|
|
* @property {OutputFormatterConfiguration[]} [outputFormatters] Output formatters.
|
|
* @property {boolean} [showFound] Show found.
|
|
*/
|
|
|
|
/**
|
|
* @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.
|
|
*/
|