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
+25 -1
View File
@@ -1,10 +1,12 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, ne } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
blogKeywords,
cases,
contentItems,
posts,
siteSettings,
type BlogKeyword,
type CaseStudy,
type ContentItem,
type ContentKind,
@@ -62,6 +64,13 @@ export async function getPostBySlug(db: Db, slug: string): Promise<Post | null>
return post ?? null;
}
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
return db
.select()
.from(blogKeywords)
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
}
export async function getHomeData(db: Db): Promise<HomeData> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
@@ -102,3 +111,18 @@ export function formatDate(d: Date | null | undefined): string {
day: "numeric",
});
}
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}