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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
2c853c0e16
commit
65bc192fae
@@ -0,0 +1,328 @@
|
||||
// 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 開頭)");
|
||||
}
|
||||
Reference in New Issue
Block a user