import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; /** * ホームページの「更新情報」をビルド時に生成する。 * * git の履歴から、ページごとに次の2つを調べて、イベントとして書き出す。 * 新規: そのページが追加されたコミットの日付 * 更新: そのページを最後に変更したコミットの日付(追加より新しい場合だけ) * ただし、一度に多くのファイルを変更したコミット(表記の一括修正、画像の整理など)は * 「更新」とはみなさない。作業ログで一覧が埋まらないようにするため。 * * 生成結果はコミットしておく(git の履歴がない環境でもビルドできるように)。 */ const BULK_FILES = 5; // これを超える数のファイルを変更したコミットは一括修正とみなす type Item = { date: string; title: string; url: string; group: string; kind: "new" | "update"; }; type Commit = { date: string; files: { status: string; path: string }[] }; export default function updatesIntegration() { return { name: "build-updates", hooks: { "astro:config:setup": () => { const items: Item[] = []; try { const out = execSync( 'git log --no-merges --date=short --format="%x01%ad" --name-status', { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }, ); // 新しい順のコミットを、日付と変更ファイルの一覧に組み直す const commits: Commit[] = []; let cur: Commit | null = null; for (const line of out.split("\n")) { if (line.startsWith("\u0001")) { cur = { date: line.slice(1).trim(), files: [] }; commits.push(cur); continue; } if (!line.trim() || !cur) continue; const parts = line.split("\t"); const status = parts[0][0]; if (status === "R" || status === "C") continue; // リネーム・コピーは対象外 cur.files.push({ status, path: parts[parts.length - 1] }); } const added = new Map(); // 追加されたコミットの日付(新しい順なので最初のAが追加時) const modified = new Map(); // 最後に変更された日付(一括修正は除く) for (const c of commits) { // 一括修正かどうかは、そのコミットが変更した全ファイルの数で判定する const bulk = c.files.length > BULK_FILES; for (const f of c.files) { if (!f.path.startsWith("src/content/docs/")) continue; if (!f.path.endsWith(".mdx") || f.path.endsWith("/index.mdx")) continue; if (f.status === "A" && !added.has(f.path)) added.set(f.path, c.date); if (!bulk && !modified.has(f.path)) modified.set(f.path, c.date); } } const reiwa = (dir: string) => { const n = Number(dir.replace(/^r(\d+)d$/, "$1")); return n === 1 ? "令和元年" : `令和${n}年`; }; for (const [file, addDate] of added) { const modDate = modified.get(file) ?? ""; try { const src = readFileSync(file, "utf-8"); // 下書き(draft: true)は本番に出ないので除外する const fm = src.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (fm && /^draft:\s*true\s*$/m.test(fm[1])) continue; const m = src.match(/^title:\s*"?(.+?)"?\s*$/m); if (!m) continue; const slug = file .replace(/^src\/content\/docs\//, "") .replace(/\.mdx$/, ""); let group = ""; const mm = slug.match(/^ippan-situmon\/(r\d+d)\/(\d+)gatu\//); if (mm) group = `${reiwa(mm[1])}${mm[2]}月定例会`; const base = { title: m[1], url: `/${slug}/`, group }; // 追加より後に更新があれば、更新のイベントも出す if (modDate > addDate) { items.push({ ...base, date: modDate, kind: "update" }); } items.push({ ...base, date: addDate, kind: "new" }); } catch { // 追加後に改名・削除されたファイルは飛ばす } } items.sort( (a, b) => b.date.localeCompare(a.date) || a.url.localeCompare(b.url), ); } catch (e) { console.warn("[updates] git から取得できませんでした:", (e as Error).message); } try { mkdirSync("src/data", { recursive: true }); writeFileSync("src/data/updates.json", JSON.stringify(items.slice(0, 40), null, 2) + "\n"); console.log( `[updates] ${items.length}件のうち直近${Math.min(items.length, 40)}件を書き出しました`, ); } catch (e) { console.warn("[updates] 書き出しに失敗しました:", (e as Error).message); } }, }, }; }