Files
jsc-marketplace/plugins/jsc-wiki/scripts/book.ts
T
rootandClaude Sonnet 5.5 65bc192fae feat: jsc-wiki 新增 book-search、book-import,領域技能可直接使用
- 新增 /jsc-wiki:book-search:上網蒐集主題並依頁面格式整理成草稿
- 新增 /jsc-wiki:book-import:判斷領域與章節,主題存在則修改、否則新增,
  重建目錄與上下頁導覽,lint 後經確認再推送到 wiki
- 寫入工具以 TypeScript 實作(scripts/book.ts,入口 book.sh)
- 領域技能補上 allowed-tools,並改用 ${CLAUDE_PLUGIN_ROOT},修正需手動核准而無法載入的問題
- 頁面格式新增兩條規則:簡化技術中文(STE100)、繁體中文無亂碼且盡量用圖表
- 版本升至 0.0.2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 05:22:09 +00:00

329 lines
15 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 <標題> <說明> 新增一章
// 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)}`);
}
function cmdAddChapter(title: string, desc: string): void {
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();
console.log(`已新增:${name.slice(0, -3)}\n提醒:領域指令 /jsc-wiki:<領域> 需另外新增對應的技能(見 README)。`);
}
// ---------------------------------------------------------------- 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": rest.length === 2 ? cmdAddChapter(rest[0], rest[1]) : die("用法:add-chapter <標題> <說明>"); 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 開頭)");
}