UNPKG

10.2 kBJavaScriptView Raw
1#!/usr/bin/env node
2
3'use strict';
4
5const fs = require('fs');
6const path = require('path');
7const Module = require('module');
8const program = require('commander');
9const options = program.opts();
10const differenceWith = require('lodash.differencewith');
11const flatten = require('lodash.flatten');
12const markdownlint = require('markdownlint');
13const rc = require('rc');
14const glob = require('glob');
15const minimatch = require('minimatch');
16const minimist = require('minimist');
17const pkg = require('./package');
18const os = require('os');
19
20function jsoncParse(text) {
21 return JSON.parse(require('jsonc-parser').stripComments(text));
22}
23
24function jsYamlSafeLoad(text) {
25 return require('js-yaml').load(text);
26}
27
28const projectConfigFiles = [
29 '.markdownlint.json',
30 '.markdownlint.yaml',
31 '.markdownlint.yml'
32];
33const configFileParsers = [jsoncParse, jsYamlSafeLoad];
34const fsOptions = {encoding: 'utf8'};
35const processCwd = process.cwd();
36
37function readConfiguration(userConfigFile) {
38 const jsConfigFile = /\.js$/i.test(userConfigFile);
39 const rcArgv = minimist(process.argv.slice(2));
40 if (jsConfigFile) {
41 // Prevent rc package from parsing .js config file as INI
42 delete rcArgv.config;
43 }
44
45 // Load from well-known config files
46 let config = rc('markdownlint', {}, rcArgv);
47 for (const projectConfigFile of projectConfigFiles) {
48 try {
49 fs.accessSync(projectConfigFile, fs.R_OK);
50 const projectConfig = markdownlint.readConfigSync(projectConfigFile, configFileParsers);
51 config = require('deep-extend')(config, projectConfig);
52 break;
53 } catch {
54 // Ignore failure
55 }
56 }
57
58 // Normally parsing this file is not needed,
59 // because it is already parsed by rc package.
60 // However I have to do it to overwrite configuration
61 // from .markdownlint.{json,yaml,yml}.
62 if (userConfigFile) {
63 try {
64 const userConfig = jsConfigFile ?
65 // Evaluate .js configuration file as code
66 require(path.resolve(processCwd, userConfigFile)) :
67 // Load JSON/YAML configuration as data
68 markdownlint.readConfigSync(userConfigFile, configFileParsers);
69 config = require('deep-extend')(config, userConfig);
70 } catch (error) {
71 console.warn('Cannot read or parse config file ' + userConfigFile + ': ' + error.message);
72 }
73 }
74
75 return config;
76}
77
78function prepareFileList(files, fileExtensions, previousResults) {
79 const globOptions = {
80 dot: Boolean(options.dot),
81 nodir: true
82 };
83 let extensionGlobPart = '*.';
84 if (fileExtensions.length === 1) {
85 // Glob seems not to match patterns like 'foo.{js}'
86 extensionGlobPart += fileExtensions[0];
87 } else {
88 extensionGlobPart += '{' + fileExtensions.join(',') + '}';
89 }
90
91 files = files.map(function (file) {
92 try {
93 if (fs.lstatSync(file).isDirectory()) {
94 // Directory (file falls through to below)
95 if (previousResults) {
96 const matcher = new minimatch.Minimatch(
97 path.resolve(processCwd, path.join(file, '**', extensionGlobPart)), globOptions);
98 return previousResults.filter(function (fileInfo) {
99 return matcher.match(fileInfo.absolute);
100 }).map(function (fileInfo) {
101 return fileInfo.original;
102 });
103 }
104
105 return glob.sync(path.join(file, '**', extensionGlobPart), globOptions);
106 }
107 } catch {
108 // Not a directory, not a file, may be a glob
109 if (previousResults) {
110 const matcher = new minimatch.Minimatch(path.resolve(processCwd, file), globOptions);
111 return previousResults.filter(function (fileInfo) {
112 return matcher.match(fileInfo.absolute);
113 }).map(function (fileInfo) {
114 return fileInfo.original;
115 });
116 }
117
118 return glob.sync(file, globOptions);
119 }
120
121 // File
122 return file;
123 });
124 return flatten(files).map(function (file) {
125 return {
126 original: file,
127 relative: path.relative(processCwd, file),
128 absolute: path.resolve(file)
129 };
130 });
131}
132
133function printResult(lintResult) {
134 const results = flatten(Object.keys(lintResult).map(file => {
135 return lintResult[file].map(result => {
136 return {
137 file: file,
138 lineNumber: result.lineNumber,
139 column: (result.errorRange && result.errorRange[0]) || 0,
140 names: result.ruleNames.join('/'),
141 description: result.ruleDescription +
142 (result.errorDetail ? ' [' + result.errorDetail + ']' : '') +
143 (result.errorContext ? ' [Context: "' + result.errorContext + '"]' : '')
144 };
145 });
146 }));
147 let lintResultString = '';
148 if (results.length > 0) {
149 results.sort((a, b) => {
150 return a.file.localeCompare(b.file) || a.lineNumber - b.lineNumber ||
151 a.names.localeCompare(b.names) || a.description.localeCompare(b.description);
152 });
153 lintResultString = results.map(result => {
154 const {file, lineNumber, column, names, description} = result;
155 const columnText = column ? `:${column}` : '';
156 return `${file}:${lineNumber}${columnText} ${names} ${description}`;
157 }).join('\n');
158 // Note: process.exit(1) will end abruptly, interrupting asynchronous IO
159 // streams (e.g., when the output is being piped). Just set the exit code
160 // and let the program terminate normally.
161 // @see {@link https://nodejs.org/dist/latest-v8.x/docs/api/process.html#process_process_exit_code}
162 // @see {@link https://github.com/igorshubovych/markdownlint-cli/pull/29#issuecomment-343535291}
163 process.exitCode = 1;
164 }
165
166 if (options.output) {
167 lintResultString = lintResultString.length > 0 ?
168 lintResultString + os.EOL :
169 lintResultString;
170 try {
171 fs.writeFileSync(options.output, lintResultString);
172 } catch (error) {
173 console.warn('Cannot write to output file ' + options.output + ': ' + error.message);
174 process.exitCode = 2;
175 }
176 } else if (lintResultString) {
177 console.error(lintResultString);
178 }
179}
180
181function concatArray(item, array) {
182 array.push(item);
183 return array;
184}
185
186program
187 .version(pkg.version)
188 .description(pkg.description)
189 .usage('[options] <files|directories|globs>')
190 .option('-c, --config [configFile]', 'configuration file (JSON, JSONC, JS, or YAML)')
191 .option('-d, --dot', 'include files/folders with a dot (for example `.github`)')
192 .option('-f, --fix', 'fix basic errors (does not work with STDIN)')
193 .option('-i, --ignore [file|directory|glob]', 'file(s) to ignore/exclude', concatArray, [])
194 .option('-o, --output [outputFile]', 'write issues to file (no console)')
195 .option('-p, --ignore-path [file]', 'path to file with ignore pattern(s)')
196 .option('-r, --rules [file|directory|glob|package]', 'custom rule files', concatArray, [])
197 .option('-s, --stdin', 'read from STDIN (does not work with files)');
198
199program.parse(process.argv);
200
201function tryResolvePath(filepath) {
202 try {
203 if (path.basename(filepath) === filepath && path.extname(filepath) === '') {
204 // Looks like a package name, resolve it relative to cwd
205 // Get list of directories, where requested module can be.
206 let paths = Module._nodeModulePaths(processCwd);
207 // eslint-disable-next-line unicorn/prefer-spread
208 paths = paths.concat(Module.globalPaths);
209 if (require.resolve.paths) {
210 // Node >= 8.9.0
211 return require.resolve(filepath, {paths: paths});
212 }
213
214 return Module._resolveFilename(filepath, {paths: paths});
215 }
216
217 // Maybe it is a path to package installed locally
218 return require.resolve(path.join(processCwd, filepath));
219 } catch {
220 return filepath;
221 }
222}
223
224function loadCustomRules(rules) {
225 return flatten(rules.map(function (rule) {
226 try {
227 const resolvedPath = [tryResolvePath(rule)];
228 const fileList = flatten(prepareFileList(resolvedPath, ['js']).map(function (filepath) {
229 return require(filepath.absolute);
230 }));
231 if (fileList.length === 0) {
232 throw new Error('No such rule');
233 }
234
235 return fileList;
236 } catch (error) {
237 console.error('Cannot load custom rule ' + rule + ': ' + error.message);
238 process.exit(3);
239 }
240 }));
241}
242
243let ignorePath = '.markdownlintignore';
244let {existsSync} = fs;
245if (options.ignorePath) {
246 ignorePath = options.ignorePath;
247 existsSync = () => true;
248}
249
250let ignoreFilter = () => true;
251if (existsSync(ignorePath)) {
252 const ignoreText = fs.readFileSync(ignorePath, fsOptions);
253 const ignore = require('ignore');
254 const ignoreInstance = ignore().add(ignoreText);
255 ignoreFilter = fileInfo => !ignoreInstance.ignores(fileInfo.relative);
256}
257
258const files = prepareFileList(program.args, ['md', 'markdown'])
259 .filter(value => ignoreFilter(value));
260const ignores = prepareFileList(options.ignore, ['md', 'markdown'], files);
261const customRules = loadCustomRules(options.rules);
262const diff = differenceWith(files, ignores, function (a, b) {
263 return a.absolute === b.absolute;
264}).map(function (paths) {
265 return paths.original;
266});
267
268function lintAndPrint(stdin, files) {
269 files = files || [];
270 const config = readConfiguration(options.config);
271 const lintOptions = {
272 config,
273 customRules,
274 files
275 };
276 if (stdin) {
277 lintOptions.strings = {
278 stdin
279 };
280 }
281
282 if (options.fix) {
283 const fixOptions = {
284 ...lintOptions,
285 resultVersion: 3
286 };
287 const markdownlintRuleHelpers = require('markdownlint-rule-helpers');
288 for (const file of files) {
289 fixOptions.files = [file];
290 const fixResult = markdownlint.sync(fixOptions);
291 const fixes = fixResult[file].filter(error => error.fixInfo);
292 if (fixes.length > 0) {
293 const originalText = fs.readFileSync(file, fsOptions);
294 const fixedText = markdownlintRuleHelpers.applyFixes(originalText, fixes);
295 if (originalText !== fixedText) {
296 fs.writeFileSync(file, fixedText, fsOptions);
297 }
298 }
299 }
300 }
301
302 const lintResult = markdownlint.sync(lintOptions);
303 printResult(lintResult);
304}
305
306if ((files.length > 0) && !options.stdin) {
307 lintAndPrint(null, diff);
308} else if ((files.length === 0) && options.stdin && !options.fix) {
309 const getStdin = require('get-stdin');
310 getStdin().then(lintAndPrint);
311} else {
312 program.help();
313}