Add Zod validation layer and AI blog generation

Introduce a schema-first validation layer and an AI blog generation
feature, plus one-command Cloudflare provisioning.

- src/schemas/ holds Zod input schemas for post, content, case,
  settings, AI, and keywords; parseForm() in src/lib/form.ts validates
  FormData and returns per-field errors.
- Migrate all admin POST handlers to parseForm, showing field-level
  errors and only redirecting once validation passes.
- Add blog_keywords table and posts.focus_keyword (migration 0002);
  uniqueSlug() centralised in src/data/content.ts.
- Add src/lib/ai.ts (OpenAI-compatible chat completions + optional
  Tavily research) and /admin/ai for AI settings, keyword queue, and
  draft generation.
- Add scripts/setup.mjs (npm run setup) to provision D1/KV, set
  secrets, and optionally migrate, seed, and deploy.
- Document AI secrets, Workers Builds deploy flow, and new schemas
  across README and AGENTS docs.
This commit is contained in:
2026-09-11 23:49:18 +08:00
parent 5b69bc818a
commit fdee1aabd7
41 changed files with 3240 additions and 296 deletions
+38 -24
View File
@@ -4,11 +4,17 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
import { cases } from "../../db/schema";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { caseInput } from "../../schemas";
export const prerender = false;
const db = getDb(getEnv().DB);
let errors: Record<string, string> = {};
let failedId = "";
let failedAdd = false;
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
@@ -17,36 +23,40 @@ if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
if (action === "add" || action === "save") {
const parsed = parseForm(form, caseInput);
if (!parsed.ok) {
errors = parsed.errors;
failedId = str(form, "id");
failedAdd = action === "add";
} else if (action === "add") {
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
await db.insert(cases).values({
id: crypto.randomUUID(),
title,
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
title: parsed.data.title,
location: parsed.data.location || null,
completedAt: parsed.data.completedAt || null,
description: parsed.data.description || null,
imageUrl: parsed.data.imageUrl || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
status: parsed.data.status,
updatedAt: new Date(),
});
} else {
const id = str(form, "id");
await db
.update(cases)
.set({
title: parsed.data.title,
location: parsed.data.location || null,
completedAt: parsed.data.completedAt || null,
description: parsed.data.description || null,
imageUrl: parsed.data.imageUrl || null,
status: parsed.data.status,
updatedAt: new Date(),
})
.where(eq(cases.id, id));
}
} else if (action === "save") {
const id = str(form, "id");
await db
.update(cases)
.set({
title: str(form, "title"),
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
})
.where(eq(cases.id, id));
} else if (action === "delete") {
await db.delete(cases).where(eq(cases.id, str(form, "id")));
} else if (action === "up" || action === "down") {
@@ -68,7 +78,9 @@ if (Astro.request.method === "POST") {
}
}
return Astro.redirect("/admin/cases");
if (Object.keys(errors).length === 0) {
return Astro.redirect("/admin/cases");
}
}
const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
@@ -85,6 +97,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2">
<div>
<label for="a-title">標題</label>
{failedAdd && errors.title && <span class="field-error">{errors.title}</span>}
<input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" />
</div>
<div>
@@ -119,6 +132,7 @@ const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
<div class="grid2">
<div>
<label>標題</label>
{failedId === item.id && errors.title && <span class="field-error">{errors.title}</span>}
<input name="title" type="text" value={item.title} required />
</div>
<div>