import { createHash } from "node:crypto"; import { readFile, rename, unlink, writeFile } from "node:fs/promises"; import { HttpError } from "./errors"; import { POSTS_FILE, POSTS_REL } from "./paths"; import type { BlogPost } from "./types"; /* blog-posts.json is the store. Every mutation runs read -> check revision * -> modify -> atomic write inside one in-process queue, so concurrent * requests can't interleave and an edit made from a stale copy (e.g. after * a git pull changed the file) is rejected with a 409 instead of clobbering. */ const KEY_ORDER: (keyof BlogPost)[] = [ "id", "slug", "title", "description", "date", "category", "tags", "images", "details", ]; function revisionOf(raw: string): string { return createHash("sha256").update(raw).digest("hex").slice(0, 16); } /** Stable key order + 2-space indent keeps git diffs small and readable. */ function serialize(posts: BlogPost[]): string { const ordered = posts.map((post) => { const out: Record = {}; for (const key of KEY_ORDER) { const value = post[key]; if (value === undefined) continue; if (key === "images" && Array.isArray(value) && value.length === 0) continue; out[key] = value; } // Preserve any fields this tool doesn't know about. for (const [key, value] of Object.entries(post)) { if (!(key in out) && !KEY_ORDER.includes(key as keyof BlogPost)) out[key] = value; } return out; }); return JSON.stringify(ordered, null, 2) + "\n"; } export async function readPosts(): Promise<{ posts: BlogPost[]; revision: string }> { let raw: string; try { raw = await readFile(POSTS_FILE, "utf8"); } catch { throw new HttpError(500, `cannot read ${POSTS_REL} (is REPO_DIR set to the portfolio checkout?)`); } let posts: unknown; try { posts = JSON.parse(raw); } catch { throw new HttpError(500, `${POSTS_REL} is not valid JSON`); } if (!Array.isArray(posts)) throw new HttpError(500, `${POSTS_REL} must contain an array`); return { posts: posts as BlogPost[], revision: revisionOf(raw) }; } async function writeAtomic(raw: string): Promise { const tmp = `${POSTS_FILE}.${process.pid}.tmp`; await writeFile(tmp, raw, "utf8"); try { await rename(tmp, POSTS_FILE); } catch (err) { // Windows can refuse to replace a file another process has open // (editors, file watchers); fall back to an in-place write. const code = (err as NodeJS.ErrnoException).code; if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw err; await writeFile(POSTS_FILE, raw, "utf8"); await unlink(tmp).catch(() => undefined); } } let tail: Promise = Promise.resolve(); /** * Runs `fn` against the current posts (mutating the array in place) and * writes the result. Pass the revision the client last saw to get optimistic * concurrency; omit it to apply unconditionally. */ export function mutatePosts( expectedRevision: string | undefined, fn: (posts: BlogPost[]) => T | Promise ): Promise<{ result: T; revision: string }> { const run = tail.then(async () => { const { posts, revision } = await readPosts(); if (expectedRevision && expectedRevision !== revision) { throw new HttpError(409, `${POSTS_REL} changed on disk since it was loaded`); } const result = await fn(posts); const raw = serialize(posts); await writeAtomic(raw); return { result, revision: revisionOf(raw) }; }); tail = run.catch(() => undefined); return run; } export function findPost(posts: BlogPost[], slug: string): BlogPost { const post = posts.find((p) => p.slug === slug); if (!post) throw new HttpError(404, `no post with slug "${slug}"`); return post; } export type Count = { name: string; count: number }; /** Category slug -> post count, sorted by name. */ export function categoryCounts(posts: BlogPost[]): Count[] { const counts = new Map(); for (const p of posts) counts.set(p.category, (counts.get(p.category) ?? 0) + 1); return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name)); } /** Tag -> post count, sorted by name (case-insensitive). */ export function tagCounts(posts: BlogPost[]): Count[] { const counts = new Map(); for (const p of posts) for (const t of p.tags) counts.set(t, (counts.get(t) ?? 0) + 1); return [...counts] .map(([name, count]) => ({ name, count })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }