#!/usr/bin/env node /** * 📝 Markdown 文档格式化工具喵~ * 支持格式化标题、代码块、空行、缩进,还能生成目录! * * 用法: * node scripts/format-docs.js # 格式化默认文档 (public/docs/home.md) * node scripts/format-docs.js <文件路径> # 格式化指定文件 * node scripts/format-docs.js --dir <目录> # 格式化整个目录的 .md 文件 * node scripts/format-docs.js --check # 只检查不写入 (dry-run) * node scripts/format-docs.js --toc # 同时生成目录 */ const fs = require('fs') const path = require('path') // ======================== 配置 ======================== const CONFIG = { maxConsecutiveBlankLines: 2, // 最大连续空行数 codeBlockLang: true, // 代码块是否保留语言标记 headingSpaceBefore: true, // 标题前是否确保空行 headingSpaceAfter: true, // 标题后是否确保空行 listIndent: 2, // 列表缩进空格数 encodeSpecialChars: false, // 是否编码特殊字符 removeTrailingSpaces: true, // 是否删除行尾空格 tocMaxLevel: 3, // 目录最大标题层级 } const DEFAULT_FILE = path.resolve(__dirname, '..', 'public', 'docs', 'home.md') // ======================== 颜色工具 ======================== const color = (code) => (s) => `\x1b[${code}m${s}\x1b[0m` const green = color('32') const cyan = color('36') const yellow = color('33') const red = color('31') const bold = color('1') const dim = color('2') // ======================== 核心格式化函数 ======================== /** * 解析 Markdown 的块结构,返回块数组 * 块类型: 'heading', 'code', 'list', 'paragraph', 'empty', 'hr', 'blockquote', 'table', 'html' */ function parseBlocks(lines) { const blocks = [] let i = 0 while (i < lines.length) { const line = lines[i] const trimmed = line.trim() // 空行 if (trimmed === '') { blocks.push({ type: 'empty', lines: [line], raw: line }) i++ continue } // 代码块 (``` 或 ~~~) if (/^```/.test(trimmed) || /^~~~/.test(trimmed)) { const marker = trimmed.match(/^(```|~~~)/)[1] const lang = trimmed.slice(marker.length).trim() const start = i i++ while (i < lines.length && !lines[i].trim().startsWith(marker)) { i++ } if (i < lines.length) i++ // 跳过结束标记 const codeLines = lines.slice(start, i) blocks.push({ type: 'code', lines: codeLines, lang, marker, raw: codeLines.join('\n'), }) continue } // 标题 const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/) if (headingMatch) { const level = headingMatch[1].length blocks.push({ type: 'heading', lines: [line], level, text: headingMatch[2], raw: line, }) i++ continue } // 分割线 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) { blocks.push({ type: 'hr', lines: [line], raw: line }) i++ continue } // HTML 注释或标签 if (/^/.test(t) || /<\/\w+>/.test(t)) { i++; break } i++ } const htmlLines = lines.slice(start, i) blocks.push({ type: 'html', lines: htmlLines, raw: htmlLines.join('\n') }) continue } // 引用块 (注: 空行不吞噬,留给后续解析,避免干扰上下文的空行判断) if (/^>/.test(trimmed)) { const start = i i++ while (i < lines.length && lines[i].trimStart().startsWith('>')) { i++ } const quoteLines = lines.slice(start, i) blocks.push({ type: 'blockquote', lines: quoteLines, raw: quoteLines.join('\n') }) continue } // 表格 if (/\|/.test(trimmed) && lines[i + 1] && /^\|[\s\-:]+\|/.test(lines[i + 1].trim())) { const start = i i += 2 while (i < lines.length && /\|/.test(lines[i].trim())) { i++ } const tableLines = lines.slice(start, i) blocks.push({ type: 'table', lines: tableLines, raw: tableLines.join('\n') }) continue } // 列表项(有序或无序) if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line)) { const start = i i++ while (i < lines.length) { const t = lines[i].trim() if (t === '') { i++; break } if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])) { i++; continue } // 缩进 continuation if (/^\s{2,}/.test(lines[i])) { i++; continue } break } const listLines = lines.slice(start, i) blocks.push({ type: 'list', lines: listLines, raw: listLines.join('\n') }) continue } // 普通段落 const start = i i++ while (i < lines.length && lines[i].trim() !== '') { // 如果遇到新的块元素则停止 const t = lines[i].trim() if (/^#{1,6}\s/.test(t) || /^```/.test(t) || /^~~~/.test(t) || /^(\s*[-*+]\s|\s*\d+\.\s)/.test(lines[i])) break // 分割线 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) break i++ } const paraLines = lines.slice(start, i) blocks.push({ type: 'paragraph', lines: paraLines, raw: paraLines.join('\n') }) } return blocks } /** * 格式化文档 */ function formatMarkdown(input, options = {}) { const cfg = { ...CONFIG, ...options } const lines = input.split('\n') let blocks = parseBlocks(lines) // ---------- 格式化步骤 ---------- // 1. 确保标题前后有空行 if (cfg.headingSpaceBefore || cfg.headingSpaceAfter) { blocks = blocks.map((block, idx) => { if (block.type !== 'heading') return block const newLines = [...block.lines] // 标题前加空行(如果不是第一个块且前一个不是空行) if (cfg.headingSpaceBefore && idx > 0 && blocks[idx - 1].type !== 'empty') { newLines.unshift('') } // 标题后加空行(如果不是最后一个块且后一个不是空行) if (cfg.headingSpaceAfter && idx < blocks.length - 1 && blocks[idx + 1].type !== 'empty') { newLines.push('') } return { ...block, lines: newLines } }) } // 2. 格式化代码块 blocks = blocks.map((block, idx) => { if (block.type !== 'code') return block const newLines = [...block.lines] // 先提取代码块标记信息 (第一行是 ``` 或 ~~~) const markerMatch = newLines[0].trim().match(/^(```|~~~)/) if (!markerMatch) return block // 安全兜底 const marker = markerMatch[1] const lang = newLines[0].trim().slice(marker.length).trim().toLowerCase() // 标准化语言标记 const indent = newLines[0].match(/^\s*/)[0] newLines[0] = lang ? `${indent}${marker}${lang}` : `${indent}${marker}` // 确保代码块前后有空行(插在标准化之后,因为只动第一行) if (idx > 0 && blocks[idx - 1].type !== 'empty' && blocks[idx - 1].type !== 'code') { newLines.unshift('') } if (idx < blocks.length - 1 && blocks[idx + 1].type !== 'empty' && blocks[idx + 1].type !== 'code') { newLines.push('') } return { ...block, lines: newLines } }) // 3. 压缩多余空行 blocks = compressEmptyLines(blocks, cfg.maxConsecutiveBlankLines) // 4. 删除行尾空格 if (cfg.removeTrailingSpaces) { blocks = blocks.map((block) => ({ ...block, lines: block.lines.map((l) => l.replace(/[ \t]+$/, '')), })) } // 5. 修复列表缩进 blocks = blocks.map((block) => { if (block.type !== 'list') return block return { ...block, lines: block.lines.map((line) => { const trimmed = line.trimStart() // 检测列表标记 if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) { const indent = line.length - line.trimStart().length // 如果是顶层列表项,确保缩进为0 if (indent % cfg.listIndent !== 0) { const normalizedIndent = Math.round(indent / cfg.listIndent) * cfg.listIndent return ' '.repeat(normalizedIndent) + trimmed } } return line }), } }) // 重新拼装 const resultLines = blocks.flatMap((b) => b.lines) // 处理文件开头和结尾的空行 while (resultLines.length > 0 && resultLines[0] === '') resultLines.shift() while (resultLines.length > 0 && resultLines[resultLines.length - 1] === '') resultLines.pop() resultLines.push('') // 文件结尾留一个空行 return resultLines.join('\n') } /** * 压缩连续空行块 */ function compressEmptyLines(blocks, maxBlank) { const result = [] let blankCount = 0 for (const block of blocks) { if (block.type === 'empty') { blankCount++ if (blankCount <= maxBlank) { result.push(block) } } else { blankCount = 0 result.push(block) } } return result } // ======================== 目录生成 ======================== /** * 从文档中提取标题生成目录 */ function generateTOC(input, maxLevel = 3) { const lines = input.split('\n') const headings = [] for (const line of lines) { const match = line.match(/^(#{1,6})\s+(.+)$/) if (match) { const level = match[1].length if (level <= maxLevel) { headings.push({ level, text: match[2].trim() }) } } } if (headings.length === 0) return '' let toc = '\n## 目录\n\n' for (const h of headings) { const indent = ' '.repeat(h.level - 1) const anchor = h.text .toLowerCase() .replace(/[^\w\u4e00-\u9fa5]+/g, '-') .replace(/^-|-$/g, '') toc += `${indent}- [${h.text}](#${anchor})\n` } toc += '\n---\n' return toc } // ======================== 统计信息 ======================== function getStats(input) { const lines = input.split('\n') const blocks = parseBlocks(lines) return { totalLines: lines.length, nonEmptyLines: lines.filter((l) => l.trim() !== '').length, headings: blocks.filter((b) => b.type === 'heading').length, codeBlocks: blocks.filter((b) => b.type === 'code').length, lists: blocks.filter((b) => b.type === 'list').length, tables: blocks.filter((b) => b.type === 'table').length, blockquotes: blocks.filter((b) => b.type === 'blockquote').length, hr: blocks.filter((b) => b.type === 'hr').length, characters: input.length, } } function printStats(stats) { console.log(bold('\n📊 文档统计信息:')) console.log(` 总行数: ${cyan(String(stats.totalLines))}`) console.log(` 非空行数: ${cyan(String(stats.nonEmptyLines))}`) console.log(` 字符数: ${cyan(String(stats.characters))}`) console.log(` 标题数: ${cyan(String(stats.headings))}`) console.log(` 代码块数: ${cyan(String(stats.codeBlocks))}`) console.log(` 列表数: ${cyan(String(stats.lists))}`) console.log(` 表格数: ${cyan(String(stats.tables))}`) console.log(` 引用块数: ${cyan(String(stats.blockquotes))}`) console.log(` 分割线数: ${cyan(String(stats.hr))}`) } // ======================== 文件处理 ======================== function readFile(filePath) { try { let content = fs.readFileSync(filePath, 'utf-8') // 统一换行符为 \n,避免 Windows 的 \r\n 导致 diff 不稳定 content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n') return content } catch (err) { console.error(red(`❌ 无法读取文件: ${filePath}`)) console.error(dim(err.message)) process.exit(1) } } function writeFile(filePath, content) { try { fs.writeFileSync(filePath, content, 'utf-8') console.log(green(`✅ 已写入: ${filePath}`)) } catch (err) { console.error(red(`❌ 写入失败: ${filePath}`)) console.error(dim(err.message)) process.exit(1) } } function processFile(filePath, options) { const relativePath = path.relative(process.cwd(), filePath) console.log(bold(`\n📄 处理文件: ${cyan(relativePath)}`)) const input = readFile(filePath) const statsBefore = getStats(input) if (options.verbose) { console.log(dim(' 格式化前:')) printStats(statsBefore) } const formatted = formatMarkdown(input, options) const statsAfter = getStats(formatted) const hasChanges = input !== formatted if (options.check) { if (hasChanges) { console.log(yellow(' ⚠️ 文件需要格式化 (dry-run, 未写入)')) const added = statsAfter.totalLines - statsBefore.totalLines console.log(dim(` 行数变化: ${added > 0 ? '+' : ''}${added}`)) } else { console.log(green(' ✅ 文件已格式良好')) } return hasChanges ? 1 : 0 } if (hasChanges) { // 如果指定了 --toc,在文档开头插入目录 let output = formatted if (options.toc) { const tocPlaceholder = '' if (output.includes(tocPlaceholder)) { output = output.replace( new RegExp(`${tocPlaceholder}[\\s\\S]*?${tocPlaceholder}`), `${tocPlaceholder}\n${generateTOC(output, options.tocMaxLevel || CONFIG.tocMaxLevel)}${tocPlaceholder}` ) } else { output = generateTOC(output, options.tocMaxLevel || CONFIG.tocMaxLevel) + '\n' + output } } writeFile(filePath, output) console.log(green(' ✨ 格式化完成!')) if (options.verbose) { printStats(statsAfter) } } else { console.log(green(' ✅ 文档已经格式良好,无需修改')) } return 0 } function processDirectory(dirPath, options) { let exitCode = 0 const files = fs .readdirSync(dirPath) .filter((f) => f.endsWith('.md')) .map((f) => path.join(dirPath, f)) if (files.length === 0) { console.log(yellow(`⚠️ 在 ${dirPath} 中未找到 .md 文件`)) return 0 } console.log(bold(`\n📁 扫描目录: ${cyan(dirPath)} (${files.length} 个文件)\n`)) for (const file of files) { const ret = processFile(file, options) if (ret !== 0) exitCode = ret } return exitCode } // ======================== CLI ======================== function printHelp() { console.log(bold(` 📝 Markdown 文档格式化工具 v1.0.0 ${cyan('用法:')} node scripts/format-docs.js [文件路径] [选项] ${cyan('参数:')} 文件路径 要格式化的 .md 文件 (默认: public/docs/home.md) ${cyan('选项:')} --dir, -d <目录> 格式化整个目录下的所有 .md 文件 --check, -c dry-run 模式,只检查不写入 --toc, -t 在文档中生成目录 --verbose, -v 显示详细统计信息 --help, -h 显示帮助信息 ${cyan('示例:')} node scripts/format-docs.js node scripts/format-docs.js README.md node scripts/format-docs.js --dir docs/ node scripts/format-docs.js --check node scripts/format-docs.js --toc --verbose `)) } function parseArgs() { const args = process.argv.slice(2) const options = { file: null, dir: null, check: false, toc: false, verbose: false, help: false, } for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--dir': case '-d': options.dir = args[++i] break case '--check': case '-c': options.check = true break case '--toc': case '-t': options.toc = true break case '--verbose': case '-v': options.verbose = true break case '--help': case '-h': options.help = true break default: if (!args[i].startsWith('--') && !args[i].startsWith('-')) { options.file = path.resolve(process.cwd(), args[i]) } } } return options } // ======================== 主函数 ======================== function main() { const options = parseArgs() if (options.help) { printHelp() return 0 } console.log(bold(`\n${cyan('🐱 文档格式化工具')} ${dim('(用❤️制作)')}\n`)) if (options.dir) { const dirPath = path.resolve(process.cwd(), options.dir) if (!fs.existsSync(dirPath)) { console.error(red(`❌ 目录不存在: ${dirPath}`)) return 1 } return processDirectory(dirPath, options) } const filePath = options.file || DEFAULT_FILE if (!fs.existsSync(filePath)) { console.error(red(`❌ 文件不存在: ${filePath}`)) return 1 } return processFile(filePath, options) } // ======================== 启动 ======================== if (require.main === module) { const exitCode = main() process.exit(exitCode) } module.exports = { formatMarkdown, generateTOC, getStats, parseBlocks }