Files
jsc-marketplace/plugins/jsc-wiki/scripts/book.ts
T

425 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// JSC 知識庫(wiki)寫入工具:讓 wiki 維持「一本書」的結構。
// 用 scripts/book.sh 執行(需要 Node 22.6 以上,直接執行 TypeScript)。
//
// sync 取得/更新可寫入的工作副本,印出路徑
// chapters 列出章節(章號、標題、說明、篇數)
// find <關鍵字>... 找已存在的主題(標題與內文)
// add <章號> --title T --source S --file F 新增頁面(自動編號)
// add-chapter <標題> <說明> --key <名稱> [--skills-dir <目錄>] 新增一章並產生領域技能
// index 重建章節頁、Home 目錄與每頁的上下頁導覽
// lint [檔案...] 檢查頁面格式(不給檔案則檢查有變更的頁面)
// diff 顯示尚未推送的變更
// push <訊息> commit 並推送(用 tea 預設 login 的 token)
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
const REPO = process.env.JSC_WIKI_URL ?? "https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.wiki.git";
const WORK =
process.env.JSC_WIKI_WORK ||
join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "jsc-wiki-work");
const NAV = "<!-- nav -->";
const die = (msg: string): never => {
console.error(msg);
process.exit(1);
};
function run(args: string[], opts: { cwd?: string; check?: boolean } = {}): string {
const [cmd, ...rest] = args;
const argv = cmd === "git" ? ["-c", "core.quotepath=false", ...rest] : rest;
const r = spawnSync(cmd, argv, { cwd: opts.cwd, encoding: "utf8" });
if ((opts.check ?? true) && r.status !== 0) die(`指令失敗:${args.join(" ")}\n${(r.stderr ?? "").trim()}`);
return r.stdout ?? "";
}
const read = (p: string): string => readFileSync(p, "utf8");
const write = (p: string, s: string): void => writeFileSync(p, s, "utf8");
const inWork = (f: string): string => join(WORK, f);
function token(): string | undefined {
if (process.env.JSC_WIKI_TOKEN) return process.env.JSC_WIKI_TOKEN;
const cfg = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "tea", "config.yml");
if (!existsSync(cfg)) return undefined;
const host = new URL(REPO).host;
const logins: Record<string, string>[] = [];
for (const line of read(cfg).split("\n")) {
const s = line.trim();
if (s.startsWith("- name:")) logins.push({});
else if (logins.length && s.includes(":")) {
const i = s.indexOf(":");
const k = s.slice(0, i);
if (k === "url" || k === "token" || k === "default") logins[logins.length - 1][k] = s.slice(i + 1).trim().replace(/^["']|["']$/g, "");
}
}
const mine = logins.filter((l) => (l.url ?? "").includes(host));
return (mine.find((l) => l.default === "true") ?? mine[0])?.token;
}
function sync(quiet = true): string {
if (existsSync(join(WORK, ".git"))) {
if (run(["git", "status", "--porcelain"], { cwd: WORK }).trim()) {
if (!quiet) console.error("工作副本有未推送的變更,略過更新。");
} else run(["git", "pull", "-q", "--ff-only"], { cwd: WORK, check: false });
} else {
mkdirSync(dirname(WORK), { recursive: true });
run(["git", "clone", "-q", REPO, WORK]);
}
return WORK;
}
const slug = (t: string): string => t.replace(/[\\/?#%*"<>|]/g, "").trim().replace(/\s+/g, "-");
const link = (t: string): string => encodeURIComponent(slug(t));
type Pos = [number, number];
function chapterFiles(): [number, string][] {
const out: [number, string][] = [];
for (const f of readdirSync(WORK)) {
const m = f.match(/^第(\d+)章-/);
if (m) out.push([Number(m[1]), f]);
}
return out.sort((a, b) => a[0] - b[0]);
}
function pageFiles(n?: number): [Pos, string][] {
const out: [Pos, string][] = [];
for (const f of readdirSync(WORK)) {
const m = f.match(/^(\d+)\.(\d+)-.+\.md$/);
if (m && (n === undefined || Number(m[1]) === n)) out.push([[Number(m[1]), Number(m[2])], f]);
}
return out.sort((a, b) => a[0][0] - b[0][0] || a[0][1] - b[0][1]);
}
function chapterInfo(f: string): [string, string] {
const lines = read(inWork(f)).split("\n");
const title = lines[0].replace(/^#\s*/, "").trim();
const desc = lines.slice(1).map((l) => l.trim()).find((l) => l && !/^[-\[<#]/.test(l)) ?? "";
return [title, desc];
}
const pageTitle = (f: string): string => read(inWork(f)).split("\n", 1)[0].replace(/^#\s*/, "").trim();
const pageSource = (f: string): string => read(inWork(f)).match(/來源:([^\n]+)/)?.[1].trim() ?? "";
const stripNav = (s: string): string => (s.includes("\n" + NAV) ? s.split("\n" + NAV)[0].trimEnd() + "\n" : s.trimEnd() + "\n");
// ---------------------------------------------------------------- index
function makeIndex(): void {
const chs = chapterFiles();
const info = new Map(chs.map(([n, f]) => [n, chapterInfo(f)] as const));
const ordered: [string, string][] = []; // 全書順序:[頁名, 顯示標題]
for (const [n, f] of chs) {
ordered.push([f.slice(0, -3), info.get(n)![0]]);
for (const [[a, b], pf] of pageFiles(n)) ordered.push([pf.slice(0, -3), `${a}.${b} ${pageTitle(pf)}`]);
}
const navOf = new Map<string, [[string, string], [string, string]]>();
ordered.forEach(([s], i) => {
navOf.set(s, [ordered[i - 1] ?? ["Home", "目錄"], ordered[i + 1] ?? ["Home", "目錄"]]);
});
const navBlock = (s: string, pf?: string): string => {
const [prev, next] = navOf.get(s)!;
let chap = "";
if (pf) {
const cf = chs.find(([k]) => k === Number(pf.split(".")[0]))![1];
chap = ` · [本章目錄](${link(cf.slice(0, -3))})`;
}
return `\n${NAV}\n---\n\n[← ${prev[1]}](${link(prev[0])})${chap} · [全書目錄](Home) · [${next[1]} →](${link(next[0])})\n`;
};
for (const [n, f] of chs) {
const [t, d] = info.get(n)!;
const rows = pageFiles(n).map(([[a, b], pf]) => {
const src = pageSource(pf).replace(" 知識庫", "");
return `- [${a}.${b} ${pageTitle(pf)}](${link(pf.slice(0, -3))})` + (src ? `(${src})` : "");
});
write(inWork(f), `# ${t}\n\n${d}\n\n${rows.join("\n")}\n` + navBlock(f.slice(0, -3)));
}
for (const [, pf] of pageFiles()) write(inWork(pf), stripNav(read(inWork(pf))) + navBlock(pf.slice(0, -3), pf));
const toc = chs
.map(([n, f], i) => `${i + 1}. [${info.get(n)![0]}](${link(f.slice(0, -3))}):${info.get(n)![1]}(${pageFiles(n).length} 篇)`)
.join("\n");
let home = read(inWork("Home.md"));
home = home.replace(/(## 目錄\n\n)[\s\S]*?(\n\n## )/, (_, a, b) => a + toc + b);
home = home.replace(/依領域分為\s*\S{1,3}\s*章/, `依領域分為 ${chs.length} 章`);
write(inWork("Home.md"), home);
}
// ---------------------------------------------------------------- commands
function cmdChapters(): void {
sync();
for (const [n, f] of chapterFiles()) {
const [t, d] = chapterInfo(f);
console.log(`${n}\t${t}\t${d}\t${pageFiles(n).length} 篇`);
for (const [[a, b], pf] of pageFiles(n)) console.log(`\t${a}.${b} ${pageTitle(pf)}`);
}
}
function cmdFind(kws: string[]): void {
sync();
const keys = kws.map((k) => k.toLowerCase());
const hits = pageFiles()
.map(([, pf]) => {
const body = read(inWork(pf)).toLowerCase();
const title = pageTitle(pf).toLowerCase();
const score = keys.reduce((s, k) => s + (title.includes(k) ? 3 : 0) + (body.includes(k) ? 1 : 0), 0);
return { score, pf };
})
.filter((h) => h.score)
.sort((a, b) => b.score - a.score)
.slice(0, 15);
if (!hits.length) console.log("沒有相符的頁面。");
for (const h of hits) console.log(`${h.score}\t${h.pf.slice(0, -3)}\t${pageTitle(h.pf)}`);
}
function cmdAdd(args: string[]): void {
const opt = (name: string): string => {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] ? args[i + 1] : die(`缺少 ${name}`);
};
const chapter = Number(args[0]);
const title = opt("--title");
const source = opt("--source");
const file = opt("--file");
sync();
const ch = chapterFiles().find(([n]) => n === chapter);
if (!ch) die(`沒有第 ${chapter} 章。可用章號:${chapterFiles().map(([n]) => n).join(", ")}`);
const ctitle = chapterInfo(ch![1])[0].replace(/^第\d+章\s*/, "");
const body = read(file).replace(/^\s*#\s[^\n]*\n/, "").replace(/^\s*>\s*第[^\n]*\n/, "");
const next = Math.max(0, ...pageFiles(chapter).map(([[, b]]) => b)) + 1;
const name = `${chapter}.${next}-${slug(title)}.md`;
write(inWork(name), `# ${title}\n\n> 第${chapter}章 ${ctitle} · 來源:${source}\n\n${body.trim()}\n`);
makeIndex();
console.log(`已新增:${name.slice(0, -3)}`);
}
const RESERVED = new Set(["wiki", "book-search", "book-import"]);
function domainSkill(key: string, n: number, title: string, desc: string): string {
const brief = desc.replace(/"/g, "").replace(/。$/, "");
const t = title.replace(/"/g, "");
return `---
name: ${key}
allowed-tools: Bash(\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh *)
description: "查詢${t}的知識(${brief})。使用者執行 /jsc-wiki:${key} 或詢問與「${t}」相關的操作步驟時使用。"
---
# JSC 知識庫・第${n}章 ${t}
目前章節內容(來自 wiki,唯讀):
!\`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter ${n}\`
## 流程
0. 上方若顯示錯誤或沒有內容,先自行執行 \`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter N\`(N 為本章號)。
1. 使用者沒給主題(\`$ARGUMENTS\` 為空):只列出上面的篇目並詢問要看哪一篇,不要全文貼出。
2. 有給主題:用 \`\${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh page <編號>\`(例如 \`${n}.1\`)讀取最相關的一到兩篇,依內容回答;找不到再用 \`wiki.sh search <關鍵字>\` 跨章搜尋。
3. 只依 wiki 內容回答並註明頁面編號;wiki 沒寫的就說沒有,不要憑記憶補指令。
4. 內容中 \`<密碼>\`、\`<Runner token>\` 等是占位符,不要猜測實際值;執行會改動系統的指令前先跟使用者確認。
回覆使用繁體中文(程式碼、指令除外)。
`;
}
/** 新增一章,並產生對應的領域技能(skills/<key>/SKILL.md),同步更新 README 表格與 wiki 技能的指令清單。 */
function cmdAddChapter(args: string[]): void {
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const key = flag("--key") ?? die("缺少 --key <領域指令名稱>(英文小寫,例如 security)");
const skillsDir = flag("--skills-dir") ?? join(dirname(process.argv[1]), "..", "skills");
const pos = args.filter((a, i) => !a.startsWith("--") && !args[i - 1]?.startsWith("--"));
if (pos.length !== 2) die("用法:add-chapter <標題> <說明> --key <名稱> [--skills-dir <目錄>]");
const [title, desc] = pos;
if (!/^[a-z][a-z0-9-]*$/.test(key)) die(`--key 只能用英文小寫、數字與連字號:${key}`);
if (RESERVED.has(key)) die(`--key 不可使用保留名稱:${key}`);
const skillFile = join(skillsDir, key, "SKILL.md");
if (existsSync(skillFile)) die(`已有同名技能:${skillFile}`);
sync();
const n = Math.max(0, ...chapterFiles().map(([k]) => k)) + 1;
const name = `第${n}章-${slug(title)}.md`;
write(inWork(name), `# 第${n}章 ${title}\n\n${desc}\n`);
makeIndex();
mkdirSync(dirname(skillFile), { recursive: true });
write(skillFile, domainSkill(key, n, title, desc));
const touched = [skillFile];
const readme = join(skillsDir, "..", "README.md");
if (existsSync(readme)) {
const lines = read(readme).split("\n");
const last = lines.map((l) => l.startsWith("| `/jsc-wiki:")).lastIndexOf(true);
if (last >= 0) {
lines.splice(last + 1, 0, `| \`/jsc-wiki:${key}\` | ${n} ${title} | ${desc.replace(/。$/, "")} |`);
write(readme, lines.join("\n"));
touched.push(readme);
}
}
const wikiSkill = join(skillsDir, "wiki", "SKILL.md");
if (existsSync(wikiSkill)) {
const lines = read(wikiSkill).split("\n");
const i = lines.findIndex((l) => l.includes("各領域指令:"));
if (i >= 0) {
lines[i] = lines[i].replace(/。$/, `、\`:${key}\`。`);
write(wikiSkill, lines.join("\n"));
touched.push(wikiSkill);
}
}
// 在 marketplace repo 內:同步市集 README、市集 manifest 與插件 manifest 的指令清單,並升一個修訂版本
const pluginDir = join(skillsDir, "..");
const repoRoot = join(pluginDir, "..", "..");
const edit = (file: string, fn: (s: string) => string): void => {
if (!existsSync(file)) return;
const before = read(file);
const after = fn(before);
if (after !== before) {
write(file, after);
touched.push(file);
}
};
const bump = (v: string): string => v.replace(/(\d+\.\d+\.)(\d+)/, (_, a, b) => a + (Number(b) + 1));
let version = "";
edit(join(pluginDir, ".claude-plugin", "plugin.json"), (j) => {
j = j.replace(/(:[a-z0-9-]+)(;\/jsc-wiki:book-search)/, `$1、:${key}$2`);
return j.replace(/("version": ")([^"]+)"/, (_, a, v) => `${a}${(version = bump(v))}"`);
});
edit(join(repoRoot, ".claude-plugin", "marketplace.json"), (j) =>
j.replace(/(system(?:、[a-z0-9-]+)*)( 指令)/, `$1、${key}$2`));
edit(join(repoRoot, "README.md"), (r) => {
r = r.replace(/(### \[jsc-wiki\]\(plugins\/jsc-wiki\) `)[^`]+`/, (_, a) => `${a}${version || "未知"}\``);
return r.replace(/(### \[jsc-wiki\][\s\S]*?`:[a-z0-9-]+`)( 等指令)/, `$1、\`:${key}\`$2`);
});
console.log(`已新增:${name.slice(0, -3)}(wiki 工作副本,尚未推送)`);
console.log(`已產生領域技能 /jsc-wiki:${key},變更的檔案:\n${touched.map((f) => " " + f).join("\n")}`);
console.log("提醒:技能檔在插件目錄,需 commit 進 jsc-marketplace repo 才會保留;重新載入插件後指令才會出現。");
}
// ---------------------------------------------------------------- lint
const MOJIBAKE = /�|[ÃÂãäåæçèéï][\u0080-¿]|\?{3,}/;
const MARKETING = ["強大", "無縫", "輕鬆", "極致", "完美", "革命性", "頂尖", "卓越", "高效能", "一鍵搞定"];
const SECRET = /(password|passwd|token|secret|api[_-]?key)\s*[=:]\s*['"]?(?!<)[A-Za-z0-9/+_\-*%&]{8,}/i;
/** 去掉程式碼區塊;四個以上的反引號可包住三個。回傳文字行與區塊是否成對。 */
function stripCode(text: string): { lines: string[]; balanced: boolean } {
const lines: string[] = [];
let mark = 0;
for (const l of text.split("\n")) {
const m = l.match(/^(`{3,})/);
if (m) {
if (!mark) mark = m[1].length;
else if (m[1].length >= mark && l.trim() === m[1]) mark = 0;
continue;
}
if (!mark) lines.push(l);
}
return { lines, balanced: !mark };
}
function lintFile(path: string): { errs: string[]; warns: string[] } {
const errs: string[] = [];
const warns: string[] = [];
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
} catch {
return { errs: ["不是有效的 UTF-8"], warns };
}
if (text.startsWith("")) errs.push("檔案開頭有 BOM,請移除");
if (text.includes("\r")) errs.push("含有 CR 換行,請改用 LF");
if (MOJIBAKE.test(text)) errs.push("疑似亂碼(出現 U+FFFD、ä¸ 之類的字元或連續問號)");
if (SECRET.test(text)) errs.push("疑似含有密碼或 token,請改成 <密碼>、<token> 占位符");
const { lines: body, balanced } = stripCode(text);
if (!balanced) errs.push("程式碼區塊 ``` 沒有成對");
const lines = text.split("\n");
if (!lines[0].startsWith("# ")) errs.push("第一行必須是 `# 標題`");
if (!lines.slice(0, 6).some((l) => l.startsWith("> 第") && l.includes("來源:"))) errs.push("標題後需要來源列:`> 第N章 領域 · 來源:…`");
const heads = body.filter((l) => /^#{1,6} /.test(l));
if (!heads.includes("## 簡介")) errs.push("缺少 `## 簡介`");
if (!["## 步驟", "## 參考連結", "## 提示詞"].some((h) => heads.includes(h))) errs.push("缺少 `## 步驟`");
heads.filter((h) => h.startsWith("### ")).forEach((h, i) => {
if (!h.startsWith(`### ${i + 1}. `) && /\d/.test(h[4] ?? "")) warns.push(`步驟編號不連續:${h}`);
});
const prose = body.filter((l) => l.trim() && !/^(#|>|\||<!--|---|- \[|\[|!)/.test(l));
for (const l of prose) {
const l2 = l.replace(/`[^`]*`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
const noUrl = l2.replace(/https?:\/\/\S+/g, "");
if (l2.includes(";") || noUrl.includes(";")) warns.push(`STE:不使用分號:${l.trim().slice(0, 30)}…`);
for (const s of l2.split(/[。!?]/)) {
if (s.trim().length > 40) warns.push(`STE:句子超過 40 字(${s.trim().length}):${s.trim().slice(0, 24)}…`);
}
for (const w of MARKETING) if (l2.includes(w)) warns.push(`STE:避免行銷用語「${w}」:${l.trim().slice(0, 24)}…`);
if (/[一-鿿][,;:]/.test(noUrl)) warns.push(`中文後面使用了半形標點:${l.trim().slice(0, 24)}…`);
}
for (const p of body.join("\n").split("\n\n")) {
if ((p.match(/。/g) ?? []).length > 6 && !/^(-|\||1\.|\*)/.test(p.trimStart())) warns.push("STE:一個段落超過 6 句");
}
if (!text.includes("```mermaid") && !/^\|[\s:|-]+\|$/m.test(text)) warns.push("圖表:建議加入 mermaid 圖或表格(若內容確實不適合可忽略)");
return { errs, warns };
}
function cmdLint(given: string[]): void {
sync();
let files = given;
if (!files.length) {
files = run(["git", "status", "--porcelain"], { cwd: WORK })
.split("\n")
.filter(Boolean)
.map((l) => inWork(l.slice(3).trim().replace(/^"|"$/g, "")))
.filter((f) => /^\d+\.\d+-/.test(basename(f)) && existsSync(f));
}
let bad = 0;
for (const f of files) {
const { errs, warns } = lintFile(f);
console.log(`== ${basename(f)}`);
errs.forEach((e) => console.log(" ✗", e));
[...new Set(warns)].forEach((w) => console.log(" △", w));
if (!errs.length && !warns.length) console.log(" ✓ 通過");
if (errs.length) bad++;
}
process.exit(bad ? 1 : 0);
}
function cmdDiff(): void {
sync();
console.log(run(["git", "status", "--short"], { cwd: WORK }) || "沒有變更。");
console.log(run(["git", "diff", "HEAD", "--stat"], { cwd: WORK }));
}
function cmdPush(message: string): void {
sync();
if (!run(["git", "status", "--porcelain"], { cwd: WORK }).trim()) return void console.log("沒有變更。");
makeIndex();
const ident = run(["git", "config", "user.name"], { cwd: WORK, check: false }).trim()
? []
: ["-c", "user.name=jsc-wiki", "-c", "user.email=jsc-wiki@noreply.localhost"];
run(["git", "add", "-A"], { cwd: WORK });
run(["git", ...ident, "commit", "-q", "-m", message], { cwd: WORK });
const t = token();
if (!t) die(`已 commit,但找不到 token(tea login 或 JSC_WIKI_TOKEN)。請手動推送:git -C ${WORK} push`);
run(["git", "-c", `http.extraHeader=Authorization: token ${t}`, "push", "-q", "origin", "HEAD"], { cwd: WORK });
console.log("已推送到 wiki。");
}
// ---------------------------------------------------------------- main
process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); throw e; });
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
case "sync": console.log(sync(false)); break;
case "chapters": cmdChapters(); break;
case "find": rest.length ? cmdFind(rest) : die("用法:find <關鍵字>..."); break;
case "add": cmdAdd(rest); break;
case "add-chapter": cmdAddChapter(rest); break;
case "index": sync(); makeIndex(); console.log("已重建章節頁、Home 目錄與上下頁導覽。"); break;
case "lint": cmdLint(rest); break;
case "diff": cmdDiff(); break;
case "push": rest[0] ? cmdPush(rest[0]) : die("用法:push <訊息>"); break;
default: die("用法:book.sh sync|chapters|find|add|add-chapter|index|lint|diff|push(說明見 book.ts 開頭)");
}