| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- #!/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();
|