119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
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<string, unknown> = {};
|
|
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<void> {
|
|
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<unknown> = 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<T>(
|
|
expectedRevision: string | undefined,
|
|
fn: (posts: BlogPost[]) => T | Promise<T>
|
|
): 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<string, number>();
|
|
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<string, number>();
|
|
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" }));
|
|
}
|