|
|
@@ -0,0 +1,193 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+// =============================================================
|
|
|
+// Backlog 已完成记录索引生成器(Node,零依赖)
|
|
|
+//
|
|
|
+// 作用:解析 COMPLETED.md 的条目标题,生成「日期 | 编号 | 条目」倒序索引表,
|
|
|
+// 写回文件头部的 <!-- INDEX:BEGIN --> … <!-- INDEX:END --> 标记之间。
|
|
|
+//
|
|
|
+// 背景:COMPLETED.md 只增不减(防重复处理的归档),全量整读代价高。
|
|
|
+// 本索引把它变成「按编号/关键词一次命中」的检索入口。
|
|
|
+//
|
|
|
+// 用法(在项目根目录执行):
|
|
|
+// node docs/backlog/backlog-index.mjs # 刷新同目录 COMPLETED.md 的索引
|
|
|
+// node docs/backlog/backlog-index.mjs --check # 只校验索引是否最新(CI 可用)
|
|
|
+// node docs/backlog/backlog-index.mjs <文件> [--check]
|
|
|
+// node docs/backlog/backlog-index.mjs --level=3 # 条目标题为 ### 的文件(如 team-base)
|
|
|
+//
|
|
|
+// 说明:条目标题级默认为 `##`;若文件用 `###`,首次运行加 --level=3,
|
|
|
+// 脚本会把级别写入 INDEX:BEGIN 标记,之后无需再传(--check 也自动读取)。
|
|
|
+// ★ 本文件(shell-kit/tool-backlog-index.mjs)为 canonical 版本;
|
|
|
+// 各项目在 docs/backlog/backlog-index.mjs 放置副本,改动请同步回本文件。
|
|
|
+// =============================================================
|
|
|
+
|
|
|
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
|
+import { fileURLToPath } from "node:url";
|
|
|
+import { dirname, join, isAbsolute, resolve } from "node:path";
|
|
|
+
|
|
|
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
+const END_MARKER = "<!-- INDEX:END -->";
|
|
|
+const BEGIN_RE = /<!-- INDEX:BEGIN(?:\s+([^>]*?))?\s*-->/;
|
|
|
+const FULL_RE = /<!-- INDEX:BEGIN[^>]*-->[\s\S]*?<!-- INDEX:END -->/;
|
|
|
+
|
|
|
+const DATE_RE = /(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)/;
|
|
|
+const TAIL_DATE_PAREN_RE = /((\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?))\s*$/;
|
|
|
+
|
|
|
+/** 按 GitHub 风格生成标题锚点。 */
|
|
|
+function githubAnchor(title) {
|
|
|
+ let out = "";
|
|
|
+ for (const ch of title.toLowerCase()) {
|
|
|
+ if (ch === " " || ch === "-" || ch === "_") {
|
|
|
+ out += ch;
|
|
|
+ } else if (/[\p{L}\p{N}\p{M}]/u.test(ch)) {
|
|
|
+ out += ch;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out.replace(/ /g, "-");
|
|
|
+}
|
|
|
+
|
|
|
+/** 解析条目标题(跳过围栏代码块与索引小节本身)。 */
|
|
|
+function parseEntries(text, level) {
|
|
|
+ const prefix = "#".repeat(level) + " ";
|
|
|
+ const entries = [];
|
|
|
+ let inFence = false;
|
|
|
+
|
|
|
+ for (const line of text.split("\n")) {
|
|
|
+ const t = line.trim();
|
|
|
+ if (/^(?:```+|~~~+)/.test(t)) {
|
|
|
+ inFence = !inFence;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (inFence) continue;
|
|
|
+ if (!line.startsWith(prefix) || line[level] === "#") continue;
|
|
|
+
|
|
|
+ const title = line.slice(prefix.length).trim();
|
|
|
+ if (title.startsWith("📇")) continue;
|
|
|
+
|
|
|
+ const m = title.match(DATE_RE);
|
|
|
+ const date = m ? m[1].replace("T", " ") : "";
|
|
|
+ let clean = title.replace(TAIL_DATE_PAREN_RE, "").trim();
|
|
|
+
|
|
|
+ let num = "";
|
|
|
+ const lead = clean.match(/^(\d+)\.\s+/);
|
|
|
+ if (lead) {
|
|
|
+ num = "#" + lead[1];
|
|
|
+ clean = clean.replace(/^(\d+)\.\s+/, "").trim();
|
|
|
+ } else {
|
|
|
+ num = [...title.matchAll(/#(\d+)/g)].map((x) => "#" + x[1]).join(" ");
|
|
|
+ }
|
|
|
+
|
|
|
+ entries.push({ date, num, title: clean, anchor: githubAnchor(title) });
|
|
|
+ }
|
|
|
+ return entries;
|
|
|
+}
|
|
|
+
|
|
|
+/** 渲染索引块(含 BEGIN/END 标记)。 */
|
|
|
+function renderIndex(entries, level) {
|
|
|
+ const begin =
|
|
|
+ "<!-- INDEX:BEGIN" + (level !== 2 ? ` level=${level}` : "") + " -->";
|
|
|
+ const head = [
|
|
|
+ begin,
|
|
|
+ "",
|
|
|
+ "## 📇 索引(按日期倒序|检索入口)",
|
|
|
+ "",
|
|
|
+ "> **检索约定**:本文件仅用于**按编号 / 关键词检索**(如 `grep '#42'` 或搜标题关键词),**禁止整读**(只增不减的归档)。",
|
|
|
+ "> 索引由 [`backlog-index.mjs`](backlog-index.mjs) 自动生成;新增条目后运行 `node docs/backlog/backlog-index.mjs` 刷新,`--check` 校验。",
|
|
|
+ "> 编号与 [BACKLOG.md](BACKLOG.md) 共用唯一连续编号;无编号条目为日常交付/工程记录。早期条目标题未记录日期,集中在表尾(日期列为 `—`)。",
|
|
|
+ "",
|
|
|
+ "| 日期 | 编号 | 条目 |",
|
|
|
+ "|------|------|------|",
|
|
|
+ ];
|
|
|
+ const rows = entries.map((e) => {
|
|
|
+ const date = e.date ? e.date.slice(0, 10) : "—";
|
|
|
+ const num = e.num || "—";
|
|
|
+ const title = e.title.replace(/\|/g, "\\|");
|
|
|
+ return `| ${date} | ${num} | [${title}](#${e.anchor}) |`;
|
|
|
+ });
|
|
|
+ return [...head, ...rows, END_MARKER].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+/** 生成新文件内容。 */
|
|
|
+function build(text, level) {
|
|
|
+ const entries = parseEntries(text, level);
|
|
|
+ // 按日期倒序(同日保持文件内原相对顺序;JS sort 稳定)
|
|
|
+ entries.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
|
|
+ const index = renderIndex(entries, level);
|
|
|
+
|
|
|
+ const full = text.match(FULL_RE);
|
|
|
+ let head, tail;
|
|
|
+ if (full) {
|
|
|
+ head = text.slice(0, full.index);
|
|
|
+ tail = text.slice(full.index + full[0].length);
|
|
|
+ } else {
|
|
|
+ const i = text.indexOf("\n---\n");
|
|
|
+ if (i === -1) {
|
|
|
+ console.error("❌ 未找到首次插入位置(缺少 `---` 分隔符)");
|
|
|
+ process.exit(2);
|
|
|
+ }
|
|
|
+ head = text.slice(0, i);
|
|
|
+ tail = text.slice(i);
|
|
|
+ }
|
|
|
+ const result =
|
|
|
+ head.replace(/\n+$/, "") +
|
|
|
+ "\n\n" +
|
|
|
+ index +
|
|
|
+ "\n\n" +
|
|
|
+ tail.replace(/^\n+/, "");
|
|
|
+ return result.endsWith("\n") ? result : result + "\n";
|
|
|
+}
|
|
|
+
|
|
|
+function main() {
|
|
|
+ const args = process.argv.slice(2);
|
|
|
+ let check = false;
|
|
|
+ let level = null;
|
|
|
+ let target = null;
|
|
|
+
|
|
|
+ for (const a of args) {
|
|
|
+ if (a === "--check") check = true;
|
|
|
+ else if (a.startsWith("--level=")) level = Number.parseInt(a.slice(8), 10);
|
|
|
+ else if (a.startsWith("--")) {
|
|
|
+ console.error(`❌ 未知参数:${a}`);
|
|
|
+ process.exit(2);
|
|
|
+ } else target = a;
|
|
|
+ }
|
|
|
+
|
|
|
+ let file = target
|
|
|
+ ? isAbsolute(target)
|
|
|
+ ? target
|
|
|
+ : resolve(process.cwd(), target)
|
|
|
+ : join(SCRIPT_DIR, "COMPLETED.md");
|
|
|
+ if (existsSync(file) && !file.endsWith(".md")) file = join(file, "COMPLETED.md");
|
|
|
+
|
|
|
+ let text;
|
|
|
+ try {
|
|
|
+ text = readFileSync(file, "utf8");
|
|
|
+ } catch {
|
|
|
+ console.error(`❌ 无法读取文件:${file}`);
|
|
|
+ process.exit(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 级别:CLI 优先,其次读现有标记,最后默认 2
|
|
|
+ if (level === null) {
|
|
|
+ const m = text.match(BEGIN_RE);
|
|
|
+ const lm = m && m[1] && m[1].match(/level=(\d+)/);
|
|
|
+ level = lm ? Number.parseInt(lm[1], 10) : 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ const next = build(text, level);
|
|
|
+ if (check) {
|
|
|
+ if (next !== text) {
|
|
|
+ console.error("❌ 索引不是最新,请运行:node docs/backlog/backlog-index.mjs");
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+ console.log("✅ 索引已是最新");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (next === text) {
|
|
|
+ console.log("✅ 索引无变化");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ writeFileSync(file, next, "utf8");
|
|
|
+ console.log(`✅ 已刷新索引(${parseEntries(next, level).length} 条)`);
|
|
|
+}
|
|
|
+
|
|
|
+main();
|