initial commit
This commit is contained in:
26
lib/client.ts
Normal file
26
lib/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Browser-side fetch helper for the JSON API. */
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(url: string, init?: RequestInit & { json?: unknown }): Promise<T> {
|
||||
const { json, ...rest } = init ?? {};
|
||||
const res = await fetch(url, {
|
||||
...rest,
|
||||
headers: json !== undefined ? { "content-type": "application/json", ...rest.headers } : rest.headers,
|
||||
body: json !== undefined ? JSON.stringify(json) : rest.body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const details = Array.isArray(data.details) ? `: ${data.details.join("; ")}` : "";
|
||||
throw new ApiError(res.status, (data.error ?? res.statusText) + details, data.details);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
10
lib/errors.ts
Normal file
10
lib/errors.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** An error that maps directly onto an HTTP response (see lib/http.ts). */
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
45
lib/files.ts
Normal file
45
lib/files.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PUBLIC_DIR, REPO_DIR, safeJoin } from "./paths";
|
||||
|
||||
/* Read-only file serving from the portfolio checkout, so the preview iframe
|
||||
* can load the site's real stylesheets and the post's images. */
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".mp4": "video/mp4",
|
||||
};
|
||||
|
||||
/** Stylesheets the preview may load, keyed by their /site/... path. */
|
||||
const SITE_CSS = new Set(["app/globals.css", "styles/markdown-container.css", "styles/blog.css"]);
|
||||
|
||||
async function send(file: string): Promise<Response> {
|
||||
const type = TYPES[path.extname(file).toLowerCase()];
|
||||
if (!type) return new Response("not found", { status: 404 });
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
return new Response(new Uint8Array(body), {
|
||||
headers: { "content-type": type, "cache-control": "no-cache" },
|
||||
});
|
||||
} catch {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/** /site/<css allowlist> -> repo file; /site/<anything else> -> repo public/. */
|
||||
export function serveSitePath(segments: string[]): Promise<Response> {
|
||||
const rel = segments.join("/");
|
||||
if (SITE_CSS.has(rel)) return send(path.join(REPO_DIR, rel));
|
||||
return send(safeJoin(PUBLIC_DIR, ...segments));
|
||||
}
|
||||
|
||||
export function servePublic(segments: string[]): Promise<Response> {
|
||||
return send(safeJoin(PUBLIC_DIR, ...segments));
|
||||
}
|
||||
80
lib/git.ts
Normal file
80
lib/git.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { IMG_REL, POSTS_REL, REPO_DIR } from "./paths";
|
||||
|
||||
/* Thin wrapper over the git CLI, run against the portfolio checkout. No
|
||||
* shell is involved (execFile), and every command is scoped to the paths the
|
||||
* manager owns, so unrelated working-tree changes are never committed. */
|
||||
|
||||
export const MANAGED_PATHS = [POSTS_REL, IMG_REL];
|
||||
|
||||
export type GitResult = { ok: boolean; code: number; stdout: string; stderr: string };
|
||||
|
||||
function gitEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (process.env.GIT_PUSH_TOKEN) {
|
||||
env.GIT_ASKPASS =
|
||||
process.env.GIT_ASKPASS_PATH ?? path.join(/*turbopackIgnore: true*/ process.cwd(), "scripts", "git-askpass.sh");
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function configArgs(): string[] {
|
||||
// The bind-mounted repo is usually owned by a different uid than the
|
||||
// container user; safe.directory from the command line is honored.
|
||||
const args = ["-c", `safe.directory=${REPO_DIR}`];
|
||||
if (process.env.GIT_AUTHOR_NAME) args.push("-c", `user.name=${process.env.GIT_AUTHOR_NAME}`);
|
||||
if (process.env.GIT_AUTHOR_EMAIL) args.push("-c", `user.email=${process.env.GIT_AUTHOR_EMAIL}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function git(args: string[], timeoutMs = 60_000): Promise<GitResult> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"git",
|
||||
[...configArgs(), ...args],
|
||||
{ cwd: REPO_DIR, env: gitEnv(), timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
||||
resolve({ ok: !err, code, stdout: String(stdout), stderr: String(stderr || err?.message || "") });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export type ChangedFile = { code: string; path: string };
|
||||
|
||||
export type GitStatus = {
|
||||
available: boolean;
|
||||
error?: string;
|
||||
branch?: string;
|
||||
/** Commits relative to the upstream as of the last fetch. */
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
changes: ChangedFile[];
|
||||
diffStat?: string;
|
||||
};
|
||||
|
||||
export async function gitStatus(): Promise<GitStatus> {
|
||||
const status = await git(["status", "--porcelain=v1", "-z", "-uall", "--", ...MANAGED_PATHS]);
|
||||
if (!status.ok) return { available: false, error: status.stderr.trim(), changes: [] };
|
||||
|
||||
const changes: ChangedFile[] = [];
|
||||
const entries = status.stdout.split("\0");
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.length < 4) continue;
|
||||
const code = entry.slice(0, 2);
|
||||
changes.push({ code: code.trim() || code, path: entry.slice(3) });
|
||||
if (code[0] === "R" || code[0] === "C") i++; // skip the rename source
|
||||
}
|
||||
|
||||
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
let ahead: number | undefined;
|
||||
let behind: number | undefined;
|
||||
const counts = await git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]);
|
||||
if (counts.ok) [behind, ahead] = counts.stdout.trim().split(/\s+/).map(Number);
|
||||
|
||||
const diffStat = (await git(["diff", "--stat", "HEAD", "--", ...MANAGED_PATHS])).stdout.trim();
|
||||
return { available: true, branch, ahead, behind, changes, diffStat };
|
||||
}
|
||||
40
lib/http.ts
Normal file
40
lib/http.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
/* There is deliberately no authentication (access is controlled by firewall
|
||||
* rules). The one guard: mutating requests from a browser must originate
|
||||
* from this app, so a page on another site can't POST to the LAN service. */
|
||||
function checkOrigin(req: Request): void {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return; // curl and other non-browser clients
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
throw new HttpError(403, "bad origin");
|
||||
}
|
||||
if (originHost !== req.headers.get("host")) throw new HttpError(403, "cross-origin request refused");
|
||||
}
|
||||
|
||||
/** Wraps a route handler: origin check for writes + HttpError -> JSON response. */
|
||||
export function route<C>(handler: (req: Request, ctx: C) => Promise<Response>) {
|
||||
return async (req: Request, ctx: C): Promise<Response> => {
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") checkOrigin(req);
|
||||
return await handler(req, ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return Response.json({ error: err.message, details: err.details }, { status: err.status });
|
||||
}
|
||||
console.error("[blog-manager]", err);
|
||||
return Response.json({ error: "internal error" }, { status: 500 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function readJson<T = Record<string, unknown>>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new HttpError(400, "request body must be JSON");
|
||||
}
|
||||
}
|
||||
107
lib/images.ts
Normal file
107
lib/images.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { HttpError } from "./errors";
|
||||
import { IMG_ROOT, safeJoin } from "./paths";
|
||||
import { SLUG_RE } from "./validate";
|
||||
import type { BlogPost, ImageFile } from "./types";
|
||||
|
||||
/* Uploaded media lives in portfolio/public/img/blog/<slug>/ and is referenced
|
||||
* from posts as "img/blog/<slug>/<file>" (no leading slash, as the site's
|
||||
* card and gallery components prepend "/"). */
|
||||
|
||||
export const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4"];
|
||||
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export function postImageDir(slug: string): string {
|
||||
if (!SLUG_RE.test(slug)) throw new HttpError(400, "invalid slug");
|
||||
return safeJoin(IMG_ROOT, slug);
|
||||
}
|
||||
|
||||
export function imageRelSrc(slug: string, name: string): string {
|
||||
return `img/blog/${slug}/${name}`;
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
return stat(p).then(() => true, () => false);
|
||||
}
|
||||
|
||||
export async function listImages(slug: string): Promise<ImageFile[]> {
|
||||
const dir = postImageDir(slug);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: ImageFile[] = [];
|
||||
for (const name of names.sort()) {
|
||||
if (!ALLOWED_EXT.includes(path.extname(name).toLowerCase())) continue;
|
||||
const info = await stat(path.join(dir, name));
|
||||
if (info.isFile()) files.push({ name, src: imageRelSrc(slug, name), size: info.size });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** "My Screenshot (2).PNG" -> "my-screenshot-2.png" */
|
||||
function sanitizeName(original: string): string {
|
||||
const ext = path.extname(original).toLowerCase();
|
||||
const base = path
|
||||
.basename(original, path.extname(original))
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^[-.]+|-+$/g, "");
|
||||
return (base || "image") + ext;
|
||||
}
|
||||
|
||||
export async function saveUpload(slug: string, file: File): Promise<ImageFile> {
|
||||
const name = sanitizeName(file.name);
|
||||
const ext = path.extname(name);
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
throw new HttpError(400, `${file.name}: only ${ALLOWED_EXT.join(", ")} files are allowed`);
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new HttpError(413, `${file.name}: larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
|
||||
}
|
||||
|
||||
const dir = postImageDir(slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
// Never overwrite: name.png, name-1.png, name-2.png, ...
|
||||
const stem = path.basename(name, ext);
|
||||
let finalName = name;
|
||||
for (let i = 1; await exists(path.join(dir, finalName)); i++) finalName = `${stem}-${i}${ext}`;
|
||||
|
||||
await writeFile(safeJoin(dir, finalName), Buffer.from(await file.arrayBuffer()));
|
||||
return { name: finalName, src: imageRelSrc(slug, finalName), size: file.size };
|
||||
}
|
||||
|
||||
export async function deleteImage(slug: string, name: string): Promise<void> {
|
||||
const target = safeJoin(postImageDir(slug), name);
|
||||
if (path.dirname(target) !== postImageDir(slug)) throw new HttpError(400, "invalid file name");
|
||||
await rm(target, { force: true });
|
||||
}
|
||||
|
||||
export async function deleteImageDir(slug: string): Promise<void> {
|
||||
await rm(postImageDir(slug), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a post's media folder on slug rename and rewrites every reference to
|
||||
* it in the post (gallery entries and inline body links).
|
||||
*/
|
||||
export async function renameImageDir(post: BlogPost, from: string, to: string): Promise<void> {
|
||||
const src = postImageDir(from);
|
||||
const dest = postImageDir(to);
|
||||
if (await exists(src)) {
|
||||
if (await exists(dest)) throw new HttpError(409, `image folder for "${to}" already exists`);
|
||||
await rename(src, dest);
|
||||
}
|
||||
const oldPrefix = `img/blog/${from}/`;
|
||||
const newPrefix = `img/blog/${to}/`;
|
||||
post.details = post.details.split(oldPrefix).join(newPrefix);
|
||||
post.images = post.images?.map((img) =>
|
||||
typeof img === "string"
|
||||
? img.replace(oldPrefix, newPrefix)
|
||||
: { ...img, src: img.src.replace(oldPrefix, newPrefix) }
|
||||
);
|
||||
}
|
||||
20
lib/markdown.ts
Normal file
20
lib/markdown.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { remark } from "remark";
|
||||
import remarkParse from "remark-parse";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import remarkRehype from "remark-rehype";
|
||||
|
||||
/**
|
||||
* Identical to renderMarkdown() in portfolio/data/content.ts, so the preview
|
||||
* renders exactly what the live post page will. allowDangerousHtml is
|
||||
* intentional: posts embed raw HTML blocks (e.g. <blockquote>).
|
||||
*/
|
||||
export async function renderMarkdown(source: string): Promise<string> {
|
||||
const re = await remark()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeStringify)
|
||||
.process(source);
|
||||
return re?.toString() ?? "";
|
||||
}
|
||||
26
lib/paths.ts
Normal file
26
lib/paths.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from "node:path";
|
||||
import { HttpError } from "./errors";
|
||||
|
||||
/** The portfolio checkout. In Docker it is bind-mounted at /repo; for local
|
||||
* `npm run dev` it defaults to the sibling ../portfolio folder. */
|
||||
export const REPO_DIR = path.resolve(/*turbopackIgnore: true*/
|
||||
process.env.REPO_DIR ?? path.join(/*turbopackIgnore: true*/ process.cwd(), "..", "portfolio")
|
||||
);
|
||||
|
||||
/** Repo-relative paths the manager owns. Publishing commits only these. */
|
||||
export const POSTS_REL = "data/blog-posts.json";
|
||||
export const IMG_REL = "public/img/blog";
|
||||
|
||||
export const POSTS_FILE = path.join(REPO_DIR, POSTS_REL);
|
||||
export const PUBLIC_DIR = path.join(REPO_DIR, "public");
|
||||
export const IMG_ROOT = path.join(REPO_DIR, IMG_REL);
|
||||
|
||||
/** path.resolve(root, ...segments), refusing anything that escapes root. */
|
||||
export function safeJoin(root: string, ...segments: string[]): string {
|
||||
const resolved = path.resolve(/*turbopackIgnore: true*/ root, ...segments);
|
||||
const rel = path.relative(root, resolved);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new HttpError(400, "path escapes its root");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
77
lib/publish.ts
Normal file
77
lib/publish.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { git, MANAGED_PATHS } from "./git";
|
||||
|
||||
/* Publish = commit the managed paths -> push -> ask the live site to
|
||||
* redeploy via its existing POST /api/deploy route (which drops the flag
|
||||
* scripts/deploy-watch.sh polls for). Each step is reported, and push /
|
||||
* deploy are skipped rather than failed when they aren't configured. */
|
||||
|
||||
export type PublishStep = {
|
||||
step: "stage" | "commit" | "push" | "deploy";
|
||||
status: "ok" | "skipped" | "failed";
|
||||
output: string;
|
||||
};
|
||||
|
||||
function pushEnabled(): boolean {
|
||||
return (process.env.GIT_PUSH ?? "true").toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
async function triggerDeploy(): Promise<PublishStep> {
|
||||
const url = process.env.DEPLOY_URL;
|
||||
const secret = process.env.DEPLOY_TRIGGER_SECRET;
|
||||
if (!url || !secret) {
|
||||
return { step: "deploy", status: "skipped", output: "DEPLOY_URL / DEPLOY_TRIGGER_SECRET not set" };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const body = await res.text();
|
||||
return {
|
||||
step: "deploy",
|
||||
status: res.ok ? "ok" : "failed",
|
||||
output: `${res.status} ${res.statusText} ${body}`.trim(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { step: "deploy", status: "failed", output: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function publish(message: string): Promise<{ ok: boolean; steps: PublishStep[] }> {
|
||||
const steps: PublishStep[] = [];
|
||||
const done = (ok: boolean) => ({ ok, steps });
|
||||
|
||||
const add = await git(["add", "-A", "--", ...MANAGED_PATHS]);
|
||||
steps.push({ step: "stage", status: add.ok ? "ok" : "failed", output: add.stderr.trim() });
|
||||
if (!add.ok) return done(false);
|
||||
|
||||
const staged = await git(["diff", "--cached", "--name-only", "--", ...MANAGED_PATHS]);
|
||||
const files = staged.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
||||
if (files.length === 0) {
|
||||
steps.push({ step: "commit", status: "skipped", output: "nothing to commit" });
|
||||
} else {
|
||||
// Naming the files commits exactly these, even if other changes are staged.
|
||||
const commit = await git(["commit", "-m", message, "--", ...files]);
|
||||
steps.push({
|
||||
step: "commit",
|
||||
status: commit.ok ? "ok" : "failed",
|
||||
output: (commit.stdout + commit.stderr).trim(),
|
||||
});
|
||||
if (!commit.ok) return done(false);
|
||||
}
|
||||
|
||||
if (!pushEnabled()) {
|
||||
steps.push({ step: "push", status: "skipped", output: "GIT_PUSH=false" });
|
||||
steps.push({ step: "deploy", status: "skipped", output: "push is disabled" });
|
||||
return done(true);
|
||||
}
|
||||
|
||||
const push = await git(["push"], 120_000);
|
||||
steps.push({ step: "push", status: push.ok ? "ok" : "failed", output: (push.stdout + push.stderr).trim() });
|
||||
if (!push.ok) return done(false);
|
||||
|
||||
const deploy = await triggerDeploy();
|
||||
steps.push(deploy);
|
||||
return done(deploy.status !== "failed");
|
||||
}
|
||||
118
lib/store.ts
Normal file
118
lib/store.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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" }));
|
||||
}
|
||||
38
lib/types.ts
Normal file
38
lib/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Mirrors BlogPost / GalleryImage in portfolio/data/content.ts. Keep in sync:
|
||||
* the portfolio type-checks blog-posts.json against its own copy at build. */
|
||||
|
||||
export type GalleryImage = string | {
|
||||
src: string;
|
||||
/** Used as the image's alt text and tooltip. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type BlogPost = {
|
||||
id: number;
|
||||
slug: string; // kebab-case, URL-safe
|
||||
title: string;
|
||||
description: string; // 1-2 sentence excerpt shown on cards
|
||||
date: string; // ISO date, e.g. "2026-09-14"
|
||||
category: string; // slug, e.g. "meta" — one category per post
|
||||
tags: string[];
|
||||
images?: GalleryImage[]; // optional; first image is the card cover
|
||||
details: string; // markdown body
|
||||
};
|
||||
|
||||
/** A post as submitted by the editor: everything but the server-assigned id. */
|
||||
export type PostInput = Omit<BlogPost, "id">;
|
||||
|
||||
export type ImageFile = {
|
||||
name: string;
|
||||
/** Path as stored in BlogPost.images (no leading slash). */
|
||||
src: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function imageSrc(image: GalleryImage): string {
|
||||
return typeof image === "string" ? image : image.src;
|
||||
}
|
||||
|
||||
export function imageDescription(image: GalleryImage): string | undefined {
|
||||
return typeof image === "string" ? undefined : image.description;
|
||||
}
|
||||
20
lib/useSystemDark.ts
Normal file
20
lib/useSystemDark.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const QUERY = "(prefers-color-scheme: dark)";
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
const mq = window.matchMedia(QUERY);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}
|
||||
|
||||
/** Whether the OS is in dark mode (false during SSR). */
|
||||
export function useSystemDark(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(QUERY).matches,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
103
lib/validate.ts
Normal file
103
lib/validate.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/* Pure helpers shared by the editor (client) and the API (server). */
|
||||
import type { GalleryImage, PostInput } from "./types";
|
||||
|
||||
export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** "Hello, World!" -> "hello-world" */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Same as categoryLabel() in portfolio/data/content.ts. */
|
||||
export function categoryLabel(slug: string): string {
|
||||
return slug
|
||||
.split(/[-_]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Trim, drop empties, dedupe case-insensitively (first spelling wins). */
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of tags) {
|
||||
const tag = raw.trim();
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function cleanImage(raw: unknown): GalleryImage | null {
|
||||
const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
|
||||
const src = typeof raw === "string" ? raw : obj?.src;
|
||||
if (typeof src !== "string") return null;
|
||||
const cleaned = src.trim().replace(/^\/+/, "");
|
||||
if (!cleaned || cleaned.split("/").includes("..")) return null;
|
||||
const description = typeof obj?.description === "string" ? obj.description.trim() : "";
|
||||
return description ? { src: cleaned, description } : cleaned;
|
||||
}
|
||||
|
||||
/** Validates and normalizes an editor submission. */
|
||||
export function validatePost(raw: unknown): { post: PostInput | null; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const str = (key: string) => (typeof r[key] === "string" ? (r[key] as string).trim() : "");
|
||||
|
||||
const slug = str("slug");
|
||||
const title = str("title");
|
||||
const description = str("description");
|
||||
const date = str("date");
|
||||
const category = str("category");
|
||||
const details = typeof r.details === "string" ? r.details : "";
|
||||
|
||||
if (!SLUG_RE.test(slug)) errors.push("slug must be lowercase kebab-case (a-z, 0-9, -)");
|
||||
if (!title) errors.push("title is required");
|
||||
if (!description) errors.push("description is required");
|
||||
if (!DATE_RE.test(date) || Number.isNaN(Date.parse(date))) errors.push("date must be YYYY-MM-DD");
|
||||
if (!SLUG_RE.test(category)) errors.push("category must be lowercase kebab-case");
|
||||
if (!Array.isArray(r.tags) || r.tags.some((t) => typeof t !== "string")) {
|
||||
errors.push("tags must be a list of strings");
|
||||
}
|
||||
|
||||
const images: GalleryImage[] = [];
|
||||
if (r.images !== undefined) {
|
||||
if (!Array.isArray(r.images)) errors.push("images must be a list");
|
||||
else
|
||||
for (const item of r.images) {
|
||||
const img = cleanImage(item);
|
||||
if (img) images.push(img);
|
||||
else errors.push("images contains an invalid path");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) return { post: null, errors };
|
||||
return {
|
||||
post: {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
category,
|
||||
tags: normalizeTags(r.tags as string[]),
|
||||
...(images.length ? { images } : {}),
|
||||
details: !details || details.endsWith("\n") ? details : details + "\n",
|
||||
},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user