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