| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const matter = require('gray-matter'); | |
| const { buildManifest } = require('./manifest'); | |
| const { ensureDirForFile, getChangedFiles, parseArgs } = require('./lib'); | |
| function makeFinding(entry, rule, line, message) { | |
| return { | |
| severity: 'warning', | |
| rule, | |
| path: entry.source_path, | |
| line, | |
| message, | |
| owner: entry.owner, | |
| }; | |
| } | |
| function lintContent(entry, content) { | |
| const findings = []; | |
| const lines = content.split(/\r?\n/); | |
| let h1Count = 0; | |
| let previousHeadingLevel = 0; | |
| let inCodeBlock = false; | |
| for (let index = 0; index < lines.length; index += 1) { | |
| const line = lines[index]; | |
| const lineNumber = index + 1; | |
| const fence = line.match(/^\s*```(\S*)\s*$/); | |
| if (fence) { | |
| if (!inCodeBlock && !fence[1]) { | |
| findings.push( | |
| makeFinding(entry, 'markdown-code-fence-language', lineNumber, 'Code fence should declare a language.'), | |
| ); | |
| } | |
| inCodeBlock = !inCodeBlock; | |
| continue; | |
| } | |
| if (inCodeBlock) { | |
| continue; | |
| } | |
| const heading = line.match(/^(#{1,6})(\s*)(.*)$/); | |
| if (heading) { | |
| const level = heading[1].length; | |
| const text = heading[3].trim(); | |
| if (!text) { | |
| findings.push(makeFinding(entry, 'markdown-empty-heading', lineNumber, 'Markdown heading text is empty.')); | |
| } | |
| if (level === 1) { | |
| h1Count += 1; | |
| if (h1Count > 1) { | |
| findings.push(makeFinding(entry, 'markdown-single-h1', lineNumber, 'Document should not contain multiple H1 headings.')); | |
| } | |
| } | |
| if (previousHeadingLevel && level > previousHeadingLevel + 1) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| 'markdown-heading-increment', | |
| lineNumber, | |
| `Heading level jumps from H${previousHeadingLevel} to H${level}.`, | |
| ), | |
| ); | |
| } | |
| previousHeadingLevel = level; | |
| continue; | |
| } | |
| if (/[\u4e00-\u9fff][A-Za-z0-9]|[A-Za-z0-9][\u4e00-\u9fff]/.test(line)) { | |
| findings.push( | |
| makeFinding( | |
| entry, | |
| 'markdown-cjk-spacing', | |
| lineNumber, | |
| 'Chinese text should contain spaces around adjacent English words or numbers.', | |
| ), | |
| ); | |
| } | |
| } | |
| return findings; | |
| } | |
| function lintMarkdownStructure(options = {}) { | |
| const rootDir = options.rootDir || process.cwd(); | |
| const manifest = options.manifest || buildManifest({ rootDir }); | |
| const findings = []; | |
| for (const entry of manifest.entries) { | |
| const absPath = path.join(rootDir, entry.source_path); | |
| const parsed = matter(fs.readFileSync(absPath, 'utf8')); | |
| findings.push(...lintContent(entry, parsed.content || '')); | |
| } | |
| return findings; | |
| } | |
| function runCli() { | |
| const args = parseArgs(process.argv.slice(2)); | |
| const rootDir = args.root ? path.resolve(args.root) : process.cwd(); | |
| const files = args.changed ? getChangedFiles(rootDir) : args.files ? args.files.split(',') : null; | |
| const manifest = buildManifest({ rootDir, files }); | |
| const findings = lintMarkdownStructure({ rootDir, manifest }); | |
| const output = JSON.stringify({ schema_version: 1, findings }, null, 2); | |
| if (args.output) { | |
| const outputPath = path.resolve(rootDir, args.output); | |
| ensureDirForFile(outputPath); | |
| fs.writeFileSync(outputPath, `${output}\n`, 'utf8'); | |
| } else { | |
| process.stdout.write(`${output}\n`); | |
| } | |
| } | |
| if (require.main === module) { | |
| runCli(); | |
| } | |
| module.exports = { | |
| lintMarkdownStructure, | |
| lintContent, | |
| }; | |