#!/usr/bin/env node /** * 文档结构校验 —— 防止 Markdown 表格静默失真 * * 为什么需要这个脚本: * 2026-10-14 连续两轮评审都发现「表格被空行/blockquote 截断」导致渲染失真, * 而文件本身"改成功了"。人眼极难发现,必须机器校验。 * * 检查项: * 1. 表格被空行或 blockquote 截断(表体中间出现空行/`>` 后仍有 `|` 行) * 2. 表头被段落文本紧贴(GFM 规范:表格不能打断段落,否则整表不渲染) * 3. 未闭合的表格行(以 `|` 开头但不以 `|` 结尾) * 4. 表格列数不一致(数据行列数与表头不符) * * 用法: * node scripts/check-doc-structure.mjs # 检查全部 docs/ * node scripts/check-doc-structure.mjs docs/ 2026-10-14 # 限定路径 * * 退出码: 0 = 全部通过;1 = 发现问题 */ import { readFileSync, readdirSync } from 'node:fs'; const root = process.argv[2] || 'docs'; const SEP = /^\s*\|[\s:|-]+\|\s*$/; /** * 数表格列数。必须先把转义竖线 `\|` 与行内代码里的 `|` 排除, * 否则 `| a \| b | c |` 会被误判为 4 列(实际 3 列)。 */ function countCols(line) { // 1. 去掉行内代码段(`...`),其中的 | 不算分隔 const noCode = line.replace(/`[^`]*`/g, '§'); // 2. 转义竖线 → 占位符 const noEsc = noCode.replace(/\\\|/g, '§'); // 3. 去掉首尾竖线后按 | 切分 const t = noEsc.trim().replace(/^\|/, '').replace(/\|$/, ''); return t.split('|').length; } /** 收集所有 .md */ function collect(dir) { const out = []; const walk = (d) => { let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = `${d}/${e.name}`; if (e.isDirectory()) walk(p); else if (e.name.endsWith('.md')) out.push(p); } }; walk(dir); return out; } const files = collect(root); if (!files.length) { console.log(`[check-doc-structure] ${root} 下无 .md 文件`); process.exit(0); } const problems = []; for (const file of files) { const lines = readFileSync(file, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // ---- 检查 2:表头被段落紧贴 ---- if (SEP.test(line) && i >= 2) { const hdr = lines[i - 1].trim(); const prev = lines[i - 2].trim(); if (hdr.startsWith('|') && prev && !prev.startsWith('#') && !prev.startsWith('|') && !prev.startsWith('>') && !prev.startsWith('-') && !prev.startsWith('*') && !prev.startsWith('`')) { problems.push({ file, line: i - 1, kind: '表头被段落紧贴', detail: `表格不会渲染。需在表头前加空行。前文:「${prev.slice(0, 50)}」`, }); } } // ---- 检查 1:表格被空行/blockquote 截断 ---- if (SEP.test(line) && i >= 1 && lines[i - 1].trim().startsWith('|')) { const cols = countCols(lines[i - 1]); let j = i + 1; while (j < lines.length) { const s = lines[j].trim(); if (s.startsWith('|')) { // ---- 检查 4:列数一致性 ---- const n = countCols(lines[j]); if (n !== cols) { problems.push({ file, line: j + 1, kind: '表格列数不一致', detail: `表头 ${cols - 2} 列,本行 ${n - 2} 列`, }); } j++; continue; } // 表体中断:空行或 blockquote if (s === '' || s.startsWith('>')) { let k = j + 1; while (k < lines.length && lines[k].trim() === '') k++; if (k < lines.length && lines[k].trim().startsWith('|')) { // 中断后仍是表格行 —— 但要区分两种情况: // a) 新表的正常开始:该行是表头,且下一行是分隔线 → 合法,不是缺陷 // b) 原表的孤立续行:该行之后不是分隔线 → 缺陷(失去表头) const isNewTable = k + 1 < lines.length && SEP.test(lines[k + 1]); if (!isNewTable) { problems.push({ file, line: k + 1, kind: '表格被中断后出现孤立行', detail: `第 ${j + 1} 行是${s === '' ? '空行' : 'blockquote'},表格在此终止,` + `但第 ${k + 1} 行仍是表格行且其后无分隔线 → 该行失去表头,渲染为裸文本`, }); } } } break; } } // ---- 检查 3:未闭合表格行 ---- const t = line.trim(); if (t.startsWith('|') && !t.endsWith('|') && t.length > 1) { problems.push({ file, line: i + 1, kind: '未闭合的表格行', detail: `以 | 开头但未以 | 结尾:「${t.slice(0, 60)}」`, }); } } } if (problems.length) { console.error(`\n[check-doc-structure] ✗ 发现 ${problems.length} 处结构问题:\n`); for (const p of problems) { console.error(` ${p.file}:${p.line}`); console.error(` [${p.kind}] ${p.detail}\n`); } console.error('[check-doc-structure] 提示:Markdown 表格必须连续,说明性内容放表格之后。\n'); process.exit(1); } console.log(`[check-doc-structure] ✓ ${files.length} 个文件,表格结构全部正常`);