check-doc-structure.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. #!/usr/bin/env node
  2. /**
  3. * 文档结构校验 —— 防止 Markdown 表格静默失真
  4. *
  5. * 为什么需要这个脚本:
  6. * 2026-10-14 连续两轮评审都发现「表格被空行/blockquote 截断」导致渲染失真,
  7. * 而文件本身"改成功了"。人眼极难发现,必须机器校验。
  8. *
  9. * 检查项:
  10. * 1. 表格被空行或 blockquote 截断(表体中间出现空行/`>` 后仍有 `|` 行)
  11. * 2. 表头被段落文本紧贴(GFM 规范:表格不能打断段落,否则整表不渲染)
  12. * 3. 未闭合的表格行(以 `|` 开头但不以 `|` 结尾)
  13. * 4. 表格列数不一致(数据行列数与表头不符)
  14. *
  15. * 用法:
  16. * node scripts/check-doc-structure.mjs # 检查全部 docs/
  17. * node scripts/check-doc-structure.mjs docs/ 2026-10-14 # 限定路径
  18. *
  19. * 退出码: 0 = 全部通过;1 = 发现问题
  20. */
  21. import { readFileSync, readdirSync } from 'node:fs';
  22. const root = process.argv[2] || 'docs';
  23. const SEP = /^\s*\|[\s:|-]+\|\s*$/;
  24. /**
  25. * 数表格列数。必须先把转义竖线 `\|` 与行内代码里的 `|` 排除,
  26. * 否则 `| a \| b | c |` 会被误判为 4 列(实际 3 列)。
  27. */
  28. function countCols(line) {
  29. // 1. 去掉行内代码段(`...`),其中的 | 不算分隔
  30. const noCode = line.replace(/`[^`]*`/g, '§');
  31. // 2. 转义竖线 → 占位符
  32. const noEsc = noCode.replace(/\\\|/g, '§');
  33. // 3. 去掉首尾竖线后按 | 切分
  34. const t = noEsc.trim().replace(/^\|/, '').replace(/\|$/, '');
  35. return t.split('|').length;
  36. }
  37. /** 收集所有 .md */
  38. function collect(dir) {
  39. const out = [];
  40. const walk = (d) => {
  41. let ents;
  42. try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
  43. for (const e of ents) {
  44. const p = `${d}/${e.name}`;
  45. if (e.isDirectory()) walk(p);
  46. else if (e.name.endsWith('.md')) out.push(p);
  47. }
  48. };
  49. walk(dir);
  50. return out;
  51. }
  52. const files = collect(root);
  53. if (!files.length) {
  54. console.log(`[check-doc-structure] ${root} 下无 .md 文件`);
  55. process.exit(0);
  56. }
  57. const problems = [];
  58. for (const file of files) {
  59. const lines = readFileSync(file, 'utf8').split('\n');
  60. for (let i = 0; i < lines.length; i++) {
  61. const line = lines[i];
  62. // ---- 检查 2:表头被段落紧贴 ----
  63. if (SEP.test(line) && i >= 2) {
  64. const hdr = lines[i - 1].trim();
  65. const prev = lines[i - 2].trim();
  66. if (hdr.startsWith('|') && prev &&
  67. !prev.startsWith('#') && !prev.startsWith('|') &&
  68. !prev.startsWith('>') && !prev.startsWith('-') &&
  69. !prev.startsWith('*') && !prev.startsWith('`')) {
  70. problems.push({
  71. file, line: i - 1, kind: '表头被段落紧贴',
  72. detail: `表格不会渲染。需在表头前加空行。前文:「${prev.slice(0, 50)}」`,
  73. });
  74. }
  75. }
  76. // ---- 检查 1:表格被空行/blockquote 截断 ----
  77. if (SEP.test(line) && i >= 1 && lines[i - 1].trim().startsWith('|')) {
  78. const cols = countCols(lines[i - 1]);
  79. let j = i + 1;
  80. while (j < lines.length) {
  81. const s = lines[j].trim();
  82. if (s.startsWith('|')) {
  83. // ---- 检查 4:列数一致性 ----
  84. const n = countCols(lines[j]);
  85. if (n !== cols) {
  86. problems.push({
  87. file, line: j + 1, kind: '表格列数不一致',
  88. detail: `表头 ${cols - 2} 列,本行 ${n - 2} 列`,
  89. });
  90. }
  91. j++;
  92. continue;
  93. }
  94. // 表体中断:空行或 blockquote
  95. if (s === '' || s.startsWith('>')) {
  96. let k = j + 1;
  97. while (k < lines.length && lines[k].trim() === '') k++;
  98. if (k < lines.length && lines[k].trim().startsWith('|')) {
  99. // 中断后仍是表格行 —— 但要区分两种情况:
  100. // a) 新表的正常开始:该行是表头,且下一行是分隔线 → 合法,不是缺陷
  101. // b) 原表的孤立续行:该行之后不是分隔线 → 缺陷(失去表头)
  102. const isNewTable = k + 1 < lines.length && SEP.test(lines[k + 1]);
  103. if (!isNewTable) {
  104. problems.push({
  105. file, line: k + 1, kind: '表格被中断后出现孤立行',
  106. detail: `第 ${j + 1} 行是${s === '' ? '空行' : 'blockquote'},表格在此终止,` +
  107. `但第 ${k + 1} 行仍是表格行且其后无分隔线 → 该行失去表头,渲染为裸文本`,
  108. });
  109. }
  110. }
  111. }
  112. break;
  113. }
  114. }
  115. // ---- 检查 3:未闭合表格行 ----
  116. const t = line.trim();
  117. if (t.startsWith('|') && !t.endsWith('|') && t.length > 1) {
  118. problems.push({
  119. file, line: i + 1, kind: '未闭合的表格行',
  120. detail: `以 | 开头但未以 | 结尾:「${t.slice(0, 60)}」`,
  121. });
  122. }
  123. }
  124. }
  125. if (problems.length) {
  126. console.error(`\n[check-doc-structure] ✗ 发现 ${problems.length} 处结构问题:\n`);
  127. for (const p of problems) {
  128. console.error(` ${p.file}:${p.line}`);
  129. console.error(` [${p.kind}] ${p.detail}\n`);
  130. }
  131. console.error('[check-doc-structure] 提示:Markdown 表格必须连续,说明性内容放表格之后。\n');
  132. process.exit(1);
  133. }
  134. console.log(`[check-doc-structure] ✓ ${files.length} 个文件,表格结构全部正常`);