tool-backlog-index.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. #!/usr/bin/env node
  2. // =============================================================
  3. // Backlog 已完成记录索引生成器(Node,零依赖)
  4. //
  5. // 作用:解析 COMPLETED.md 的条目标题,生成「日期 | 编号 | 条目」倒序索引表,
  6. // 写回文件头部的 <!-- INDEX:BEGIN --> … <!-- INDEX:END --> 标记之间。
  7. //
  8. // 背景:COMPLETED.md 只增不减(防重复处理的归档),全量整读代价高。
  9. // 本索引把它变成「按编号/关键词一次命中」的检索入口。
  10. //
  11. // 用法(在项目根目录执行):
  12. // node docs/backlog/backlog-index.mjs # 刷新同目录 COMPLETED.md 的索引
  13. // node docs/backlog/backlog-index.mjs --check # 只校验索引是否最新(CI 可用)
  14. // node docs/backlog/backlog-index.mjs <文件> [--check]
  15. // node docs/backlog/backlog-index.mjs --level=3 # 条目标题为 ### 的文件(如 team-base)
  16. //
  17. // 说明:条目标题级默认为 `##`;若文件用 `###`,首次运行加 --level=3,
  18. // 脚本会把级别写入 INDEX:BEGIN 标记,之后无需再传(--check 也自动读取)。
  19. // ★ 本文件(shell-kit/tool-backlog-index.mjs)为 canonical 版本;
  20. // 各项目在 docs/backlog/backlog-index.mjs 放置副本,改动请同步回本文件。
  21. // =============================================================
  22. import { readFileSync, writeFileSync, existsSync } from "node:fs";
  23. import { fileURLToPath } from "node:url";
  24. import { dirname, join, isAbsolute, resolve } from "node:path";
  25. const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
  26. const END_MARKER = "<!-- INDEX:END -->";
  27. const BEGIN_RE = /<!-- INDEX:BEGIN(?:\s+([^>]*?))?\s*-->/;
  28. const FULL_RE = /<!-- INDEX:BEGIN[^>]*-->[\s\S]*?<!-- INDEX:END -->/;
  29. const DATE_RE = /(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)/;
  30. const TAIL_DATE_PAREN_RE = /((\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?))\s*$/;
  31. /** 按 GitHub 风格生成标题锚点。 */
  32. function githubAnchor(title) {
  33. let out = "";
  34. for (const ch of title.toLowerCase()) {
  35. if (ch === " " || ch === "-" || ch === "_") {
  36. out += ch;
  37. } else if (/[\p{L}\p{N}\p{M}]/u.test(ch)) {
  38. out += ch;
  39. }
  40. }
  41. return out.replace(/ /g, "-");
  42. }
  43. /** 解析条目标题(跳过围栏代码块与索引小节本身)。 */
  44. function parseEntries(text, level) {
  45. const prefix = "#".repeat(level) + " ";
  46. const entries = [];
  47. let inFence = false;
  48. for (const line of text.split("\n")) {
  49. const t = line.trim();
  50. if (/^(?:```+|~~~+)/.test(t)) {
  51. inFence = !inFence;
  52. continue;
  53. }
  54. if (inFence) continue;
  55. if (!line.startsWith(prefix) || line[level] === "#") continue;
  56. const title = line.slice(prefix.length).trim();
  57. if (title.startsWith("📇")) continue;
  58. const m = title.match(DATE_RE);
  59. const date = m ? m[1].replace("T", " ") : "";
  60. let clean = title.replace(TAIL_DATE_PAREN_RE, "").trim();
  61. let num = "";
  62. const lead = clean.match(/^(\d+)\.\s+/);
  63. if (lead) {
  64. num = "#" + lead[1];
  65. clean = clean.replace(/^(\d+)\.\s+/, "").trim();
  66. } else {
  67. num = [...title.matchAll(/#(\d+)/g)].map((x) => "#" + x[1]).join(" ");
  68. }
  69. entries.push({ date, num, title: clean, anchor: githubAnchor(title) });
  70. }
  71. return entries;
  72. }
  73. /** 渲染索引块(含 BEGIN/END 标记)。 */
  74. function renderIndex(entries, level) {
  75. const begin =
  76. "<!-- INDEX:BEGIN" + (level !== 2 ? ` level=${level}` : "") + " -->";
  77. const head = [
  78. begin,
  79. "",
  80. "## 📇 索引(按日期倒序|检索入口)",
  81. "",
  82. "> **检索约定**:本文件仅用于**按编号 / 关键词检索**(如 `grep '#42'` 或搜标题关键词),**禁止整读**(只增不减的归档)。",
  83. "> 索引由 [`backlog-index.mjs`](backlog-index.mjs) 自动生成;新增条目后运行 `node docs/backlog/backlog-index.mjs` 刷新,`--check` 校验。",
  84. "> 编号与 [BACKLOG.md](BACKLOG.md) 共用唯一连续编号;无编号条目为日常交付/工程记录。早期条目标题未记录日期,集中在表尾(日期列为 `—`)。",
  85. "",
  86. "| 日期 | 编号 | 条目 |",
  87. "|------|------|------|",
  88. ];
  89. const rows = entries.map((e) => {
  90. const date = e.date ? e.date.slice(0, 10) : "—";
  91. const num = e.num || "—";
  92. const title = e.title.replace(/\|/g, "\\|");
  93. return `| ${date} | ${num} | [${title}](#${e.anchor}) |`;
  94. });
  95. return [...head, ...rows, END_MARKER].join("\n");
  96. }
  97. /** 生成新文件内容。 */
  98. function build(text, level) {
  99. const entries = parseEntries(text, level);
  100. // 按日期倒序(同日保持文件内原相对顺序;JS sort 稳定)
  101. entries.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
  102. const index = renderIndex(entries, level);
  103. const full = text.match(FULL_RE);
  104. let head, tail;
  105. if (full) {
  106. head = text.slice(0, full.index);
  107. tail = text.slice(full.index + full[0].length);
  108. } else {
  109. const i = text.indexOf("\n---\n");
  110. if (i === -1) {
  111. console.error("❌ 未找到首次插入位置(缺少 `---` 分隔符)");
  112. process.exit(2);
  113. }
  114. head = text.slice(0, i);
  115. tail = text.slice(i);
  116. }
  117. const result =
  118. head.replace(/\n+$/, "") +
  119. "\n\n" +
  120. index +
  121. "\n\n" +
  122. tail.replace(/^\n+/, "");
  123. return result.endsWith("\n") ? result : result + "\n";
  124. }
  125. function main() {
  126. const args = process.argv.slice(2);
  127. let check = false;
  128. let level = null;
  129. let target = null;
  130. for (const a of args) {
  131. if (a === "--check") check = true;
  132. else if (a.startsWith("--level=")) level = Number.parseInt(a.slice(8), 10);
  133. else if (a.startsWith("--")) {
  134. console.error(`❌ 未知参数:${a}`);
  135. process.exit(2);
  136. } else target = a;
  137. }
  138. let file = target
  139. ? isAbsolute(target)
  140. ? target
  141. : resolve(process.cwd(), target)
  142. : join(SCRIPT_DIR, "COMPLETED.md");
  143. if (existsSync(file) && !file.endsWith(".md")) file = join(file, "COMPLETED.md");
  144. let text;
  145. try {
  146. text = readFileSync(file, "utf8");
  147. } catch {
  148. console.error(`❌ 无法读取文件:${file}`);
  149. process.exit(2);
  150. }
  151. // 级别:CLI 优先,其次读现有标记,最后默认 2
  152. if (level === null) {
  153. const m = text.match(BEGIN_RE);
  154. const lm = m && m[1] && m[1].match(/level=(\d+)/);
  155. level = lm ? Number.parseInt(lm[1], 10) : 2;
  156. }
  157. const next = build(text, level);
  158. if (check) {
  159. if (next !== text) {
  160. console.error("❌ 索引不是最新,请运行:node docs/backlog/backlog-index.mjs");
  161. process.exit(1);
  162. }
  163. console.log("✅ 索引已是最新");
  164. return;
  165. }
  166. if (next === text) {
  167. console.log("✅ 索引无变化");
  168. return;
  169. }
  170. writeFileSync(file, next, "utf8");
  171. console.log(`✅ 已刷新索引(${parseEntries(next, level).length} 条)`);
  172. }
  173. main();