425 lines
20 KiB
TypeScript
425 lines
20 KiB
TypeScript
// 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 開頭)");
|
||
}
|