initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

View 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
View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ ok: true });
}

View 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) });
});

View 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
View 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
View 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
View 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
View 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 });
});

19
app/categories/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import TaxonomyTable from "@/components/TaxonomyTable";
import { categoryCounts, readPosts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function CategoriesPage() {
const { posts, revision } = await readPosts();
return (
<main>
<h1>Categories</h1>
<p className="muted">
Each post has exactly one category. Categories come from the posts themselves: create one by
typing it into a post&apos;s category field, and it disappears once no post uses it. Rename to an
existing category to merge the two.
</p>
<TaxonomyTable kind="category" items={categoryCounts(posts)} revision={revision} />
</main>
);
}

View File

@@ -0,0 +1,10 @@
import { route } from "@/lib/http";
import { servePublic } from "@/lib/files";
export const dynamic = "force-dynamic";
/** Post bodies link images as /img/...; serve those from the site's public/
* so absolute links resolve in the preview exactly as on the live site. */
export const GET = route<{ params: Promise<{ path: string[] }> }>(async (_req, { params }) =>
servePublic(["img", ...(await params).path])
);

54
app/layout.tsx Normal file
View File

@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { JetBrains_Mono, DM_Sans } from "next/font/google";
import { gitStatus } from "@/lib/git";
import "./manager.css";
export const dynamic = "force-dynamic";
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--mono", display: "swap" });
const sans = DM_Sans({ subsets: ["latin"], variable: "--sans", display: "swap" });
export const metadata: Metadata = {
title: "Blog Manager",
description: "Local editor for the portfolio blog",
};
async function PublishBadge() {
const status = await gitStatus();
if (!status.available) {
return <span className="badge err" title={status.error}>git unavailable</span>;
}
const pending = status.changes.length;
const ahead = status.ahead ?? 0;
if (!pending && !ahead) return <span className="badge ok">published</span>;
return (
<Link href="/publish" className="badge warn">
{pending ? `${pending} unpublished change${pending === 1 ? "" : "s"}` : `${ahead} commit(s) not pushed`}
</Link>
);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${mono.variable} ${sans.variable}`}>
<body>
<div className="shell">
<header className="topbar">
<Link href="/" className="brand">blog-manager</Link>
<nav>
<Link href="/">posts</Link>
<Link href="/posts/new">new post</Link>
<Link href="/categories">categories</Link>
<Link href="/tags">tags</Link>
<Link href="/publish">publish</Link>
</nav>
<span className="spacer" />
<PublishBadge />
</header>
{children}
</div>
</body>
</html>
);
}

287
app/manager.css Normal file
View File

@@ -0,0 +1,287 @@
/* Palette copied from portfolio/app/globals.css so the manager looks like the site. */
:root {
--bg: #ffffff;
--bg-raised: #f8f8fa;
--surface: #f1f2f4;
--surface-hover: #e8e9ed;
--border: #d4d5db;
--border-strong: #b8bac2;
--fg: #131419;
--fg-secondary: #4e5264;
--muted: #7e8290;
--accent: #5468e0;
--accent-hover: #4758cc;
--accent-bg: rgba(84, 104, 224, 0.07);
--success: #1a8f54;
--success-bg: rgba(26, 143, 84, 0.07);
--warning: #b07818;
--warning-bg: rgba(176, 120, 24, 0.07);
--error: #c93c3c;
--error-bg: rgba(201, 60, 60, 0.07);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101114;
--bg-raised: #141518;
--surface: #1a1b1f;
--surface-hover: #212229;
--border: #2c2d34;
--border-strong: #3d3f48;
--fg: #e4e5e9;
--fg-secondary: #a0a3ad;
--muted: #6f7280;
--accent: #8a9dff;
--accent-hover: #9dacff;
--accent-bg: rgba(138, 157, 255, 0.08);
--success: #5ec98f;
--success-bg: rgba(94, 201, 143, 0.08);
--warning: #e0a855;
--warning-bg: rgba(224, 168, 85, 0.08);
--error: #e06565;
--error-bg: rgba(224, 101, 101, 0.08);
color-scheme: dark;
}
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans), system-ui, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { font-family: var(--mono), monospace; letter-spacing: -0.02em; margin: 0 0 16px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; }
code, .mono { font-family: var(--mono), monospace; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
/* ---- shell ---- */
.shell { max-width: 1400px; margin: 0 auto; padding: 0 24px 48px; }
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 18px 0;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-family: var(--mono); font-weight: 700; color: var(--accent); }
.topbar nav { display: flex; gap: 16px; font-family: var(--mono); font-size: 13px; }
.topbar nav a { color: var(--fg-secondary); }
.topbar nav a:hover { color: var(--accent); text-decoration: none; }
.topbar .spacer { flex: 1; }
.badge {
font-family: var(--mono);
font-size: 11px;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: var(--accent-bg);
color: var(--accent);
white-space: nowrap;
}
.badge.warn { background: var(--warning-bg); color: var(--warning); }
.badge.ok { background: var(--success-bg); color: var(--success); }
.badge.err { background: var(--error-bg); color: var(--error); }
/* ---- controls ---- */
button, .button {
font-family: var(--mono);
font-size: 12px;
padding: 7px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
button:hover:not(:disabled), .button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover:not(:disabled), .button.primary:hover { background: var(--accent-hover); color: #fff; }
button.danger:hover:not(:disabled) { border-color: var(--error); color: var(--error); }
button.ghost { border-color: transparent; background: transparent; padding: 5px 8px; }
input, textarea, select {
font: inherit;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 7px 10px;
width: 100%;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; }
label.field { display: flex; flex-direction: column; gap: 5px; }
label.field > span { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: lowercase; }
.field-hint { font-size: 11px; color: var(--muted); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.toolbar-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.toolbar-row input, .toolbar-row select { width: auto; min-width: 200px; }
.notice {
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.notice.error { border-color: var(--error); background: var(--error-bg); color: var(--error); }
.notice.ok { border-color: var(--success); background: var(--success-bg); color: var(--success); }
.notice.warn { border-color: var(--warning); background: var(--warning-bg); color: var(--warning); }
/* ---- tables ---- */
table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
table.list th { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 500; }
table.list tr:hover td { background: var(--bg-raised); }
table.list td.actions { text-align: right; white-space: nowrap; }
table.list td.actions > * + * { margin-left: 6px; }
.title-cell { font-weight: 600; }
.chip {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: var(--radius-sm);
margin: 1px 4px 1px 0;
}
/* ---- editor ---- */
.editor-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.editor-head h1 { margin: 0; flex: 1; min-width: 200px; }
.meta-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.meta-grid .span-all { grid-column: 1 / -1; }
.meta-grid .span-2 { grid-column: span 2; }
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
min-height: 70vh;
}
.pane {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.pane-head {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
flex-wrap: wrap;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.pane-head .spacer { flex: 1; }
.pane-head button { padding: 4px 8px; font-size: 12px; min-width: 28px; justify-content: center; }
.pane-body { flex: 1; min-height: 0; position: relative; }
.pane-body .cm-editor { height: 100%; font-size: 13.5px; }
.pane-body .cm-scroller { font-family: var(--mono), monospace; }
.preview-frame { width: 100%; height: 100%; border: 0; display: block; background: transparent; }
.tb-sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 4px; }
/* tag input */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 6px;
background: var(--bg);
}
.tag-input:focus-within { border-color: var(--accent); }
.tag-input input { border: 0; padding: 4px; flex: 1; min-width: 100px; width: auto; }
.tag-input .chip { display: inline-flex; align-items: center; gap: 4px; margin: 0; }
.tag-input .chip button { border: 0; background: none; padding: 0 2px; color: inherit; font-size: 12px; }
/* images */
.images { margin-top: 24px; }
.image-drop {
border: 1px dashed var(--border-strong);
border-radius: var(--radius-lg);
padding: 18px;
text-align: center;
color: var(--muted);
margin-bottom: 16px;
cursor: pointer;
}
.image-drop.over { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); }
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.image-card {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.image-card.in-gallery { border-color: var(--accent); }
.image-card .thumb { height: 130px; background: var(--surface); display: flex; align-items: center; justify-content: center; }
.image-card .thumb img, .image-card .thumb video { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-card .body { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.image-card .name { font-family: var(--mono); font-size: 11px; word-break: break-all; }
.image-card .row button { padding: 4px 8px; font-size: 11px; }
.log {
font-family: var(--mono);
font-size: 12px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
white-space: pre-wrap;
overflow-x: auto;
}
@media (max-width: 1000px) {
.split { grid-template-columns: 1fr; }
.split .pane { min-height: 50vh; }
.meta-grid { grid-template-columns: 1fr; }
.meta-grid .span-2 { grid-column: auto; }
}

12
app/not-found.tsx Normal file
View File

@@ -0,0 +1,12 @@
import Link from "next/link";
export default function NotFound() {
return (
<main>
<h1>Not found</h1>
<p className="muted">
That post doesn&apos;t exist (it may have been renamed or deleted). <Link href="/">Back to posts</Link>
</p>
</main>
);
}

18
app/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import PostTable from "@/components/PostTable";
import { categoryCounts, readPosts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function PostsPage() {
const { posts, revision } = await readPosts();
const sorted = [...posts].sort((a, b) => b.date.localeCompare(a.date));
return (
<main>
<PostTable
posts={sorted}
revision={revision}
categories={categoryCounts(posts).map((c) => c.name)}
/>
</main>
);
}

23
app/posts/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import PostEditor from "@/components/PostEditor";
import { listImages } from "@/lib/images";
import { categoryCounts, readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function EditPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { posts, revision } = await readPosts();
const post = posts.find((p) => p.slug === slug);
if (!post) notFound();
return (
<PostEditor
post={post}
revision={revision}
files={await listImages(slug)}
categories={categoryCounts(posts).map((c) => c.name)}
tags={tagCounts(posts).map((t) => t.name)}
/>
);
}

15
app/posts/new/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import PostEditor from "@/components/PostEditor";
import { categoryCounts, readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function NewPostPage() {
const { posts, revision } = await readPosts();
return (
<PostEditor
revision={revision}
categories={categoryCounts(posts).map((c) => c.name)}
tags={tagCounts(posts).map((t) => t.name)}
/>
);
}

20
app/publish/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import PublishPanel from "@/components/PublishPanel";
import { gitStatus } from "@/lib/git";
export const dynamic = "force-dynamic";
export default async function PublishPage() {
const status = await gitStatus();
const config = {
push: (process.env.GIT_PUSH ?? "true").toLowerCase() !== "false",
pushAuth: process.env.GIT_PUSH_TOKEN ? "token" : "git default credentials",
deploy: Boolean(process.env.DEPLOY_URL && process.env.DEPLOY_TRIGGER_SECRET),
deployUrl: process.env.DEPLOY_URL ?? null,
};
return (
<main>
<h1>Publish</h1>
<PublishPanel status={status} config={config} />
</main>
);
}

View File

@@ -0,0 +1,9 @@
import { route } from "@/lib/http";
import { serveSitePath } from "@/lib/files";
export const dynamic = "force-dynamic";
/** The site's stylesheets and public/ files, for the preview iframe. */
export const GET = route<{ params: Promise<{ path: string[] }> }>(async (_req, { params }) =>
serveSitePath((await params).path)
);

18
app/tags/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import TaxonomyTable from "@/components/TaxonomyTable";
import { readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function TagsPage() {
const { posts, revision } = await readPosts();
return (
<main>
<h1>Tags</h1>
<p className="muted">
Free-form labels shown on post cards. Rename to an existing tag to merge, or remove a tag from
every post.
</p>
<TaxonomyTable kind="tag" items={tagCounts(posts)} revision={revision} />
</main>
);
}