initial commit
This commit is contained in:
35
app/api/categories/route.ts
Normal file
35
app/api/categories/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { categoryCounts, mutatePosts, readPosts } from "@/lib/store";
|
||||
import { SLUG_RE } from "@/lib/validate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = route(async () => {
|
||||
const { posts, revision } = await readPosts();
|
||||
return Response.json({ categories: categoryCounts(posts), revision });
|
||||
});
|
||||
|
||||
/**
|
||||
* Body: { from, to, revision? }. Moves every post in `from` to `to`; if `to`
|
||||
* is already in use this is a merge. Categories are derived from posts, so a
|
||||
* category disappears once no post uses it.
|
||||
*/
|
||||
export const PATCH = route(async (req) => {
|
||||
const { from, to, revision: expected } = await readJson<{ from?: string; to?: string; revision?: string }>(req);
|
||||
const target = to?.trim() ?? "";
|
||||
if (!from) throw new HttpError(400, "from is required");
|
||||
if (!SLUG_RE.test(target)) throw new HttpError(400, "category must be lowercase kebab-case");
|
||||
|
||||
const { result, revision } = await mutatePosts(expected, (posts) => {
|
||||
let changed = 0;
|
||||
for (const post of posts) {
|
||||
if (post.category !== from) continue;
|
||||
post.category = target;
|
||||
changed++;
|
||||
}
|
||||
if (!changed) throw new HttpError(404, `no posts in category "${from}"`);
|
||||
return changed;
|
||||
});
|
||||
return Response.json({ changed: result, revision });
|
||||
});
|
||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
35
app/api/images/[slug]/route.ts
Normal file
35
app/api/images/[slug]/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { route } from "@/lib/http";
|
||||
import { deleteImage, listImages, saveUpload } from "@/lib/images";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Ctx = { params: Promise<{ slug: string }> };
|
||||
|
||||
export const GET = route<Ctx>(async (_req, { params }) => {
|
||||
const { slug } = await params;
|
||||
return Response.json({ files: await listImages(slug) });
|
||||
});
|
||||
|
||||
/** multipart/form-data with one or more `file` fields. */
|
||||
export const POST = route<Ctx>(async (req, { params }) => {
|
||||
const { slug } = await params;
|
||||
const form = await req.formData().catch(() => {
|
||||
throw new HttpError(400, "expected multipart/form-data");
|
||||
});
|
||||
const files = form.getAll("file").filter((f): f is File => f instanceof File);
|
||||
if (!files.length) throw new HttpError(400, "no files uploaded");
|
||||
|
||||
const saved = [];
|
||||
for (const file of files) saved.push(await saveUpload(slug, file));
|
||||
return Response.json({ saved, files: await listImages(slug) }, { status: 201 });
|
||||
});
|
||||
|
||||
/** Query: ?name=<file name> */
|
||||
export const DELETE = route<Ctx>(async (req, { params }) => {
|
||||
const { slug } = await params;
|
||||
const name = new URL(req.url).searchParams.get("name");
|
||||
if (!name) throw new HttpError(400, "name is required");
|
||||
await deleteImage(slug, name);
|
||||
return Response.json({ files: await listImages(slug) });
|
||||
});
|
||||
51
app/api/posts/[slug]/route.ts
Normal file
51
app/api/posts/[slug]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { deleteImageDir, renameImageDir } from "@/lib/images";
|
||||
import { findPost, mutatePosts, readPosts } from "@/lib/store";
|
||||
import { validatePost } from "@/lib/validate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Ctx = { params: Promise<{ slug: string }> };
|
||||
|
||||
export const GET = route<Ctx>(async (_req, { params }) => {
|
||||
const { slug } = await params;
|
||||
const { posts, revision } = await readPosts();
|
||||
return Response.json({ post: findPost(posts, slug), revision });
|
||||
});
|
||||
|
||||
/** Body: { post: PostInput, revision? }. A changed slug also moves the
|
||||
* post's image folder and rewrites references to it. */
|
||||
export const PUT = route<Ctx>(async (req, { params }) => {
|
||||
const { slug } = await params;
|
||||
const body = await readJson<{ post?: unknown; revision?: string }>(req);
|
||||
const { post: input, errors } = validatePost(body.post);
|
||||
if (!input) throw new HttpError(400, "invalid post", errors);
|
||||
|
||||
const { result, revision } = await mutatePosts(body.revision, async (posts) => {
|
||||
const existing = findPost(posts, slug);
|
||||
const updated = { ...input, id: existing.id };
|
||||
if (input.slug !== slug) {
|
||||
if (posts.some((p) => p.slug === input.slug)) {
|
||||
throw new HttpError(409, `a post with slug "${input.slug}" already exists`);
|
||||
}
|
||||
await renameImageDir(updated, slug, input.slug);
|
||||
}
|
||||
// images is optional: an editor that cleared the gallery drops the key.
|
||||
if (!input.images) delete (updated as { images?: unknown }).images;
|
||||
posts[posts.indexOf(existing)] = updated;
|
||||
return updated;
|
||||
});
|
||||
return Response.json({ post: result, revision });
|
||||
});
|
||||
|
||||
/** Query: ?revision=…&images=1 (also delete the post's image folder). */
|
||||
export const DELETE = route<Ctx>(async (req, { params }) => {
|
||||
const { slug } = await params;
|
||||
const url = new URL(req.url);
|
||||
const { revision } = await mutatePosts(url.searchParams.get("revision") ?? undefined, (posts) => {
|
||||
posts.splice(posts.indexOf(findPost(posts, slug)), 1);
|
||||
});
|
||||
if (url.searchParams.get("images") === "1") await deleteImageDir(slug);
|
||||
return Response.json({ deleted: slug, revision });
|
||||
});
|
||||
28
app/api/posts/route.ts
Normal file
28
app/api/posts/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { categoryCounts, mutatePosts, readPosts, tagCounts } from "@/lib/store";
|
||||
import { validatePost } from "@/lib/validate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = route(async () => {
|
||||
const { posts, revision } = await readPosts();
|
||||
return Response.json({ posts, revision, categories: categoryCounts(posts), tags: tagCounts(posts) });
|
||||
});
|
||||
|
||||
/** Body: { post: PostInput, revision? } */
|
||||
export const POST = route(async (req) => {
|
||||
const body = await readJson<{ post?: unknown; revision?: string }>(req);
|
||||
const { post, errors } = validatePost(body.post);
|
||||
if (!post) throw new HttpError(400, "invalid post", errors);
|
||||
|
||||
const { result, revision } = await mutatePosts(body.revision, (posts) => {
|
||||
if (posts.some((p) => p.slug === post.slug)) {
|
||||
throw new HttpError(409, `a post with slug "${post.slug}" already exists`);
|
||||
}
|
||||
const created = { id: Math.max(0, ...posts.map((p) => p.id)) + 1, ...post };
|
||||
posts.push(created);
|
||||
return created;
|
||||
});
|
||||
return Response.json({ post: result, revision }, { status: 201 });
|
||||
});
|
||||
8
app/api/preview/route.ts
Normal file
8
app/api/preview/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { renderMarkdown } from "@/lib/markdown";
|
||||
|
||||
/** Body: { markdown } -> { html }, rendered by the site's own pipeline. */
|
||||
export const POST = route(async (req) => {
|
||||
const { markdown } = await readJson<{ markdown?: string }>(req);
|
||||
return Response.json({ html: await renderMarkdown(markdown ?? "") });
|
||||
});
|
||||
18
app/api/publish/route.ts
Normal file
18
app/api/publish/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { gitStatus } from "@/lib/git";
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { publish } from "@/lib/publish";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = route(async () => Response.json(await gitStatus()));
|
||||
|
||||
/** Body: { message } -> commit managed paths, push, trigger deploy. A failed
|
||||
* step is reported in the body ({ ok: false, steps }), not as an HTTP error. */
|
||||
export const POST = route(async (req) => {
|
||||
const { message } = await readJson<{ message?: string }>(req);
|
||||
const msg = message?.trim();
|
||||
if (!msg) throw new HttpError(400, "commit message is required");
|
||||
const result = await publish(msg);
|
||||
return Response.json({ ...result, status: await gitStatus() });
|
||||
});
|
||||
34
app/api/tags/route.ts
Normal file
34
app/api/tags/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HttpError } from "@/lib/errors";
|
||||
import { readJson, route } from "@/lib/http";
|
||||
import { mutatePosts, readPosts, tagCounts } from "@/lib/store";
|
||||
import { normalizeTags } from "@/lib/validate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = route(async () => {
|
||||
const { posts, revision } = await readPosts();
|
||||
return Response.json({ tags: tagCounts(posts), revision });
|
||||
});
|
||||
|
||||
/**
|
||||
* Body: { from, to?, revision? }. Renames `from` to `to` on every post
|
||||
* (merging if a post already has `to`), or removes it when `to` is empty.
|
||||
*/
|
||||
export const PATCH = route(async (req) => {
|
||||
const { from, to, revision: expected } = await readJson<{ from?: string; to?: string; revision?: string }>(req);
|
||||
if (!from) throw new HttpError(400, "from is required");
|
||||
const target = to?.trim() ?? "";
|
||||
|
||||
const { result, revision } = await mutatePosts(expected, (posts) => {
|
||||
let changed = 0;
|
||||
for (const post of posts) {
|
||||
if (!post.tags.includes(from)) continue;
|
||||
const replaced = post.tags.flatMap((t) => (t === from ? (target ? [target] : []) : [t]));
|
||||
post.tags = normalizeTags(replaced);
|
||||
changed++;
|
||||
}
|
||||
if (!changed) throw new HttpError(404, `no posts tagged "${from}"`);
|
||||
return changed;
|
||||
});
|
||||
return Response.json({ changed: result, revision });
|
||||
});
|
||||
Reference in New Issue
Block a user