From fdee1aabd731e51e38831a3e00737e1c705fccf9 Mon Sep 17 00:00:00 2001 From: philipcheung Date: Fri, 11 Sep 2026 23:49:18 +0800 Subject: [PATCH] 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. --- .astro/content.d.ts | 159 -- .astro/dev.json | 4 +- .astro/settings.json | 2 +- .astro/types.d.ts | 3 +- .dev.vars.example | 2 + .gitignore | 1 + AGENTS.md | 14 +- README.md | 60 +- .../plans/2026-09-11-structure-ai-deploy.md | 1556 +++++++++++++++++ .../2026-09-11-structure-ai-deploy-design.md | 249 +++ migrations/0002_red_roughhouse.sql | 11 + migrations/AGENTS.md | 5 +- migrations/meta/0002_snapshot.json | 391 +++++ migrations/meta/_journal.json | 7 + migrations/seed.sql | 14 + package-lock.json | 9 +- package.json | 6 +- scripts/setup.mjs | 160 ++ src/AGENTS.md | 12 +- src/data/AGENTS.md | 4 +- src/data/content.ts | 26 +- src/db/AGENTS.md | 9 +- src/db/schema.ts | 24 + src/layouts/AdminLayout.astro | 7 +- src/lib/AGENTS.md | 14 +- src/lib/ai.ts | 224 +++ src/lib/env.ts | 2 + src/lib/form.ts | 23 + src/pages/admin/ai.astro | 217 +++ src/pages/admin/cases.astro | 62 +- src/pages/admin/content/[kind].astro | 55 +- src/pages/admin/index.astro | 1 + src/pages/admin/post/[id].astro | 67 +- src/pages/admin/settings.astro | 31 +- src/schemas/ai.ts | 36 + src/schemas/case.ts | 13 + src/schemas/content.ts | 13 + src/schemas/index.ts | 6 + src/schemas/keyword.ts | 11 + src/schemas/post.ts | 16 + src/schemas/settings.ts | 10 + 41 files changed, 3240 insertions(+), 296 deletions(-) delete mode 100644 .astro/content.d.ts create mode 100644 docs/superpowers/plans/2026-09-11-structure-ai-deploy.md create mode 100644 docs/superpowers/specs/2026-09-11-structure-ai-deploy-design.md create mode 100644 migrations/0002_red_roughhouse.sql create mode 100644 migrations/meta/0002_snapshot.json create mode 100644 scripts/setup.mjs create mode 100644 src/lib/ai.ts create mode 100644 src/lib/form.ts create mode 100644 src/pages/admin/ai.astro create mode 100644 src/schemas/ai.ts create mode 100644 src/schemas/case.ts create mode 100644 src/schemas/content.ts create mode 100644 src/schemas/index.ts create mode 100644 src/schemas/keyword.ts create mode 100644 src/schemas/post.ts create mode 100644 src/schemas/settings.ts diff --git a/.astro/content.d.ts b/.astro/content.d.ts deleted file mode 100644 index 2bf13df..0000000 --- a/.astro/content.d.ts +++ /dev/null @@ -1,159 +0,0 @@ -declare module 'astro:content' { - export interface RenderResult { - Content: import('astro/runtime/server/index.js').AstroComponentFactory; - headings: import('astro').MarkdownHeading[]; - remarkPluginFrontmatter: Record; - } - interface Render { - '.md': Promise; - } - - export interface RenderedContent { - html: string; - metadata?: { - imagePaths: Array; - [key: string]: unknown; - }; - } - - type Flatten = T extends { [K: string]: infer U } ? U : never; - - export type CollectionKey = keyof DataEntryMap; - export type CollectionEntry = Flatten; - - type AllValuesOf = T extends any ? T[keyof T] : never; - - export type ReferenceDataEntry< - C extends CollectionKey, - E extends keyof DataEntryMap[C] = string, - > = { - collection: C; - id: E; - }; - - export type ReferenceLiveEntry = { - collection: C; - id: string; - }; - - export function getCollection>( - collection: C, - filter?: (entry: CollectionEntry) => entry is E, - ): Promise; - export function getCollection( - collection: C, - filter?: (entry: CollectionEntry) => unknown, - ): Promise[]>; - - export function getLiveCollection( - collection: C, - filter?: LiveLoaderCollectionFilterType, - ): Promise< - import('astro').LiveDataCollectionResult, LiveLoaderErrorType> - >; - - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - entry: ReferenceDataEntry, - ): E extends keyof DataEntryMap[C] - ? Promise - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - collection: C, - id: E, - ): E extends keyof DataEntryMap[C] - ? string extends keyof DataEntryMap[C] - ? Promise | undefined - : Promise - : Promise | undefined>; - export function getLiveEntry( - collection: C, - filter: string | LiveLoaderEntryFilterType, - ): Promise, LiveLoaderErrorType>>; - - /** Resolve an array of entry references from the same collection */ - export function getEntries( - entries: ReferenceDataEntry[], - ): Promise[]>; - - export function render( - entry: DataEntryMap[C][string], - ): Promise; - - export function render( - entry: import('astro').LiveDataEntry>, - ): Promise; - - export function reference< - C extends - | keyof DataEntryMap - // Allow generic `string` to avoid excessive type errors in the config - // if `dev` is not running to update as you edit. - // Invalid collection names will be caught at build time. - | (string & {}), - >( - collection: C, - ): import('astro/zod').ZodPipe< - import('astro/zod').ZodString, - import('astro/zod').ZodTransform< - C extends keyof DataEntryMap - ? { - collection: C; - id: string; - } - : never, - string - > - >; - - type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; - type InferEntrySchema = import('astro/zod').infer< - ReturnTypeOrOriginal['schema']> - >; - type ExtractLoaderConfig = T extends { loader: infer L } ? L : never; - type InferLoaderSchema< - C extends keyof DataEntryMap, - L = ExtractLoaderConfig, - > = L extends { schema: import('astro/zod').ZodSchema } - ? import('astro/zod').infer - : any; - - type DataEntryMap = { - - }; - - type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< - infer TData, - infer TEntryFilter, - infer TCollectionFilter, - infer TError - > - ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } - : { data: never; entryFilter: never; collectionFilter: never; error: never }; - type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; - type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; - type ExtractErrorType = ExtractLoaderTypes['error']; - type ExtractDataType = ExtractLoaderTypes['data']; - - type LiveLoaderDataType = - LiveContentConfig['collections'][C]['schema'] extends undefined - ? ExtractDataType - : import('astro/zod').infer< - Exclude - >; - type LiveLoaderEntryFilterType = - ExtractEntryFilterType; - type LiveLoaderCollectionFilterType = - ExtractCollectionFilterType; - type LiveLoaderErrorType = ExtractErrorType< - LiveContentConfig['collections'][C]['loader'] - >; - - export type ContentConfig = never; - export type LiveContentConfig = never; -} diff --git a/.astro/dev.json b/.astro/dev.json index a148f3d..ea9cdba 100644 --- a/.astro/dev.json +++ b/.astro/dev.json @@ -1,5 +1,5 @@ { - "pid": 27812, + "pid": 98774, "port": 4321, "url": "http://localhost:4321", "urls": { @@ -10,5 +10,5 @@ "networkInterfaceNames": [] }, "background": true, - "startedAt": "2026-09-11T07:43:31.316Z" + "startedAt": "2026-09-11T08:49:14.193Z" } \ No newline at end of file diff --git a/.astro/settings.json b/.astro/settings.json index 885b981..3f00eb2 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -1,5 +1,5 @@ { "_variables": { - "lastUpdateCheck": 1789106959098 + "lastUpdateCheck": 1789116553811 } } \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts index d95e66d..f752936 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1,3 +1,2 @@ /// -/// -/// \ No newline at end of file +/// \ No newline at end of file diff --git a/.dev.vars.example b/.dev.vars.example index 71b3175..9eecb49 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1 +1,3 @@ ADMIN_PASSWORD=改成你自己嘅密碼 +AI_API_KEY= +TAVILY_API_KEY= diff --git a/.gitignore b/.gitignore index cf05629..9165c61 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .dev.vars *.log .DS_Store +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index b70a456..5045d3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,18 @@ Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker - `npm run preview` / `npm run deploy` — build 後用 wrangler dev / deploy - DB(本機用 `:local`,雲端省略):`npm run db:generate` → `db:migrate:local` / `db:migrate` → `db:seed:local` / `db:seed` - `npm run types` — 重新產生 `worker-configuration.d.ts`;`npm run secret` — 設定 `ADMIN_PASSWORD` +- `npm run setup` — 一鍵 Cloudflare provision(建 D1 / KV、寫 `wrangler.jsonc`、設 secret、可選套 migration + seed) - 沒有 test / lint / typecheck script(未安裝 @astrojs/check)。`npm run build` 是唯一可跑的驗證。 ## 關鍵慣例與陷阱 -- **SSR 頁面必須自己聲明**:`astro.config.mjs` 是 `output: "static"`,所有要讀 D1 的動態頁面都靠檔案內 `export const prerender = false`(首頁、blog、admin、sitemap/robots)。新增需要 request-time 資料的頁面時一定要加。 +- **SSR 頁面必須自己聲明**:`astro.config.mjs` 是 `output: "static"`,所有要讀 D1 的動態頁面都靠檔案內 `export const prerender = false`(首頁、blog、admin、sitemap.xml)。新增需要 request-time 資料的頁面時一定要加。 - **環境變數只用 `getEnv()`**(`src/lib/env.ts`,底層 `cloudflare:workers` 的 `env`)。Astro v6 起已移除 `Astro.locals.runtime.env`。`getEnv()` 只能在 `prerender = false` 的頁面/endpoint 用。 - **資料層**:`getDb(getEnv().DB)` → Drizzle;查詢集中在 `src/data/content.ts`,schema 在 `src/db/schema.ts`。改 schema 後跑 `npm run db:generate` 產生 migration。 -- **Seed 與 migration 是分開的**(README 講法有誤):`db:migrate:local` 只套 migrations,`migrations/seed.sql` 要用 `db:seed:local` 另外執行;seed 可重複跑。 +- **Seed 與 migration 是分開的**:`db:migrate:local` / `db:migrate` 只套 migrations,`migrations/seed.sql` 要用 `db:seed:local` / `db:seed` 另外執行;seed 可重複跑。 +- **表單驗證集中在 `src/schemas/`**(Zod)+ `src/lib/form.ts` 的 `parseForm(form, schema)`:admin 頁面唔好再手寫逐欄驗證。實體輸入型別用 `z.infer` 由 schemas 匯出。 +- **AI Blog**:`src/lib/ai.ts`(OpenAI 相容 chat completions + Tavily)+ `/admin/ai`;同步生成、一律存草稿並標記關鍵字 `generated`。需要 secrets `AI_API_KEY`(必需)/`TAVILY_API_KEY`(可選);未設時回明確錯誤,唔會 throw。 +- **部署用 Cloudflare Workers Builds**(Git push 自動):build `npm run build`、deploy `npx wrangler deploy`。**Migration 唔會喺 CI 執行**,改 schema 要手動 `npm run db:migrate`(雲端)。 - **`wrangler.jsonc` 有佔位 id**:`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。 - **上線前要改 `astro.config.mjs` 的 `site`**:sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。 - **後台認證**:`src/middleware.ts` 保護所有 `/admin`,用 HMAC-signed cookie(`src/lib/auth.ts`);未設 `ADMIN_PASSWORD`(本機在 `.dev.vars`,已 gitignore)會回 503。 @@ -110,12 +114,12 @@ When the user requests a durable behavior change, record it here or in the relev | Path | Scope | |---|---| -| `src/AGENTS.md` | 原始碼全層:架構、跨層慣例;擁有 `theme/`、`layouts/`、`config.ts`、`middleware.ts`、`env.d.ts`,以及 `pages/` 路由與 `/admin` 後台合約 | +| `src/AGENTS.md` | 原始碼全層:架構、跨層慣例;擁有 `theme/`、`layouts/`、`schemas/`、`config.ts`、`middleware.ts`、`env.d.ts`,以及 `pages/` 路由與 `/admin` 後台合約 | | `src/components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 | | `src/data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 | | `src/db/AGENTS.md` | Drizzle schema(migration 的唯一來源) | -| `src/lib/AGENTS.md` | auth / env / db / markdown 基礎工具 | +| `src/lib/AGENTS.md` | auth / env / db / form / ai / markdown 基礎工具 | | `migrations/AGENTS.md` | D1 migration 與 seed SQL | | `docs/AGENTS.md` | 設計規格與實作計劃(`superpowers/` 為設計權威) | -Project-level files with no child doc are owned by this root: `astro.config.mjs`、`wrangler.jsonc`、`drizzle.config.ts`、`tsconfig.json`、`package.json`、`README.md`、`.dev.vars.example`。 +Project-level files with no child doc are owned by this root: `astro.config.mjs`、`wrangler.jsonc`、`drizzle.config.ts`、`tsconfig.json`、`package.json`、`README.md`、`.dev.vars.example`、`scripts/`。 diff --git a/README.md b/README.md index cd30947..7bc33be 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **一頁式官網** `/`(SSR,讀 D1):Hero、服務、特色、流程、完成案例、FAQ、聯絡 CTA - **Blog** `/blog`、`/blog/[slug]`:封面卡片列表 + Markdown 文章頁 - **後台** `/admin`(密碼登入):管理文章、首頁內容(服務/特色/流程/FAQ)、完成案例、網站設定(公司資料/聯絡/Hero/SEO) +- **AI 生成 Blog** `/admin/ai`:設定 AI 供應商、維護關鍵字佇列,一按生成完整草稿(DeepSeek 相容 API,可選 Tavily 上網研究) - **SEO**:canonical、OG/Twitter card、`/sitemap.xml`、`/robots.txt`、結構化語意 HTML - 首頁/Blog 用邊緣快取(`s-maxage`),後台改完約 1 分鐘內生效 @@ -17,7 +18,7 @@ | 層 | 選用 | |---|---| | 前端框架 | Astro 7(`output: "static"` + per-page SSR) | -| UI | React 19 + `@chakra-ui/react` v3 + `@emotion/react`(清新天藍/綠色系 theme) | +| UI | React 19 + `@chakra-ui/react` v3 + `@emotion/react`(editorial 極簡 theme:暖米白底、琥珀金 accent) | | Adapter | `@astrojs/cloudflare`(一個 Worker) | | DB | Cloudflare D1(SQLite) | | ORM | Drizzle | @@ -32,45 +33,66 @@ ```bash npm install -# 建本機 D1 + 套 migration(migration 會連 seed.sql 一齊套) +# 建本機 D1 + 套 migration npm run db:migrate:local -# 重新匯入種子資料(可重複執行) +# 匯入種子資料(同 migration 分開,可重複執行) npm run db:seed:local # 開發 server(:4321) -npx astro dev +npm run dev ``` 本機後台密碼喺 `.dev.vars`(`ADMIN_PASSWORD=changeme-local`,已 gitignore)。 -想模擬 production(wrangler):`npx wrangler dev`。 +想模擬 production(wrangler):`npm run preview`。 ## 部署到 Cloudflare +### 首次設定(一鍵) + ```bash -npm run login # 瀏覽器授權 +npm run login # 瀏覽器授權 +npm run setup # 建立 D1 / KV、寫入 wrangler.jsonc、設 secret、套 migration + seed +``` -npm run db:create # 印 database_id → 填入 wrangler.jsonc -npm run kv:create # 印 namespace id → 填入 wrangler.jsonc +`npm run setup` 會逐步問你:要唔要設定後台密碼、要唔要套 migration/seed、要唔要 build + deploy。若 `astro.config.mjs` 嘅 `site` 仍然係 `https://example.com`,會跳過 build + deploy 並提醒你改。 -npm run db:migrate # 雲端套 migration -npm run db:seed # 雲端匯入種子(公司資料 + 樣本內容) +### 之後更新 -npx wrangler secret put ADMIN_PASSWORD # 設定後台密碼 +改完 `astro.config.mjs` 嘅 `site` 做真域名後: -# 改 astro.config.mjs 嘅 site = 真網域 +```bash npm run deploy ``` -之後更新:`npm run deploy`。 +### Git push 自動部署(Cloudflare Workers Builds) + +1. Cloudflare Dashboard → Workers & Pages → 連接 GitHub repo +2. Build command:`npm run build` +3. Deploy command:`npx wrangler deploy` +4. 喺 Dashboard 設 secrets:`ADMIN_PASSWORD`、`AI_API_KEY`、`TAVILY_API_KEY` + +**Migration 唔會喺 CI 自動執行**:改完 `src/db/schema.ts` 要手動跑 +`npm run db:generate` → `npm run db:migrate`(雲端)→ `npm run db:seed`(如有新 seed)。 + +## AI 生成 Blog + +後台 `/admin/ai`: + +- 設定 AI 供應商(OpenAI 相容 Base URL + 模型)、寫作風格、公司背景 +- 維護關鍵字佇列(新增 / 略過 / 刪除) +- 「生成草稿」= 自由輸入主題;「生成下一篇」= 攞佇列下一個 pending +- 生成係同步進行(約 10–40 秒),完成後一律存為**草稿**並跳去文章編輯頁覆核 +- 需要 secret:`AI_API_KEY`(必需)、`TAVILY_API_KEY`(如開上網研究) ## 後台點用 1. 去 `/admin`,輸入密碼登入 -2. **內容總覽** → 服務 / 特色 / 流程 / 常見問題 / 完成案例 / 網站設定 +2. **內容總覽** → 服務 / 特色 / 流程 / 常見問題 / 完成案例 / 網站設定 / AI 生成 3. 每個項目可新增、修改、刪除、用 ↑↓ 排序、「已發布 / 草稿」控制前台顯示 4. 圖片:直接貼圖片 URL(暫時;之後可換成上傳) +5. **AI 生成**:設定供應商同關鍵字佇列,生成草稿後去文章編輯頁覆核發布 ## 結構 @@ -81,18 +103,20 @@ src/ ├─ data/ │ ├─ content.ts D1 讀取 + WhatsApp/電話 link helper │ └─ settings-fields.ts 後台設定欄位定義 -├─ db/schema.ts Drizzle schema(posts / site_settings / content_items / cases) +├─ db/schema.ts Drizzle schema(posts / site_settings / content_items / cases / blog_keywords) +├─ schemas/ Zod 驗證 schema(表單輸入 + AI 輸出) ├─ layouts/Base.astro SEO head + fonts ├─ layouts/AdminLayout.astro 後台外框 ├─ pages/ │ ├─ index.astro SSR 首頁 │ ├─ blog/… Blog -│ ├─ admin/… 後台 +│ ├─ admin/… 後台(含 admin/ai) │ └─ sitemap.xml.ts / robots.txt.ts -└─ lib/{auth,db,env,markdown}.ts +├─ lib/{auth,db,env,form,ai,markdown}.ts migrations/ ├─ 0000_init.sql … Drizzle migrations -└─ seed.sql 種子資料(與 migration 一齊執行) +└─ seed.sql 種子資料(用 db:seed 另外執行) +scripts/setup.mjs 一鍵 Cloudflare provision ``` ## 待客戶提供(可喺 `/admin/settings` 自行更新) diff --git a/docs/superpowers/plans/2026-09-11-structure-ai-deploy.md b/docs/superpowers/plans/2026-09-11-structure-ai-deploy.md new file mode 100644 index 0000000..c0e5587 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-structure-ai-deploy.md @@ -0,0 +1,1556 @@ +# 結構優化 + AI Blog + 部署 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 為盈豐太陽能官網加入 Zod schema-first 驗證層、AI 生成 Blog(DeepSeek + Tavily + 關鍵字佇列),同簡化 Cloudflare 部署。 + +**Architecture:** 沿用 Astro 7 SSR + D1 + Drizzle 單 Worker 架構。Phase 1 加 `src/schemas/` + `parseForm` helper,將 admin 表單驗證集中。Phase 2 加 `blog_keywords` 表 + `src/lib/ai.ts`(OpenAI 相容 chat completions + Tavily),新 `/admin/ai` 頁同步生成草稿。Phase 3 加 `scripts/setup.mjs` 一鍵 provision,並用 Cloudflare Workers Builds 做 Git push 自動 deploy。 + +**Tech Stack:** Astro 7 / React 19 / Chakra UI v3(前台)/ Drizzle ORM / Cloudflare D1 + KV / Zod v4 / marked / DeepSeek(OpenAI 相容)/ Tavily。 + +**驗證方式:** 本 repo **冇** test / lint / typecheck script,`npm run build` 係唯一 build/type 檢查(見 `AGENTS.md`)。以下每個 Task 以 `npm run build` 收尾,需要時加 `npm run dev` 目測。**本 repo 預設唔自動 commit**;如要 commit,喺每個 Task 最後自行執行(訊息格式見範例)。 + +**Spec:** `docs/superpowers/specs/2026-09-11-structure-ai-deploy-design.md` + +--- + +## 檔案結構 + +**新增:** +``` +src/schemas/post.ts postInput +src/schemas/content.ts contentItemInput +src/schemas/case.ts caseInput +src/schemas/settings.ts settingsInput(由 SETTINGS_GROUPS 動態砌) +src/schemas/ai.ts aiSettingsInput + aiGeneratedPost +src/schemas/keyword.ts keywordInput + keywordStatus +src/schemas/index.ts barrel +src/lib/form.ts parseForm(formData, schema) +src/lib/ai.ts AI 生成流程(Tavily + DeepSeek + 解析) +src/pages/admin/ai.astro AI 設定 + 關鍵字佇列 + 生成 +scripts/setup.mjs 一鍵 Cloudflare provision +``` + +**修改:** +``` +package.json + zod、+ "setup" script +src/db/schema.ts posts.focusKeyword + blog_keywords 表 +src/lib/env.ts + AI_API_KEY / TAVILY_API_KEY +src/data/content.ts + getKeywords / uniqueSlug +src/layouts/AdminLayout.astro + "AI" 導覽、+ .field-error CSS +src/pages/admin/post/[id].astro 用 parseForm + uniqueSlug +src/pages/admin/content/[kind].astro 用 parseForm +src/pages/admin/cases.astro 用 parseForm +src/pages/admin/settings.astro 用 parseForm +src/pages/admin/index.astro 加入口連結 +migrations/seed.sql 加 AI 設定 + 示範關鍵字 +.dev.vars.example 加 AI_API_KEY / TAVILY_API_KEY +README.md / AGENTS.md 各層 同步文件 +``` + +--- + +# Phase 1 — Zod schema-first 驗證層 + +## Task 1: 加 zod 同建立 schemas + +**Files:** +- Modify: `package.json` +- Create: `src/schemas/post.ts`、`content.ts`、`case.ts`、`settings.ts`、`ai.ts`、`keyword.ts`、`index.ts` +- Create: `src/lib/form.ts` + +- [x] **Step 1: 加 zod 依賴** + +```bash +npm install zod@^4.6.1 +``` + +Expected: `package.json` dependencies 加入 `"zod": "^4.6.1"`(現時 zod 只係 astro 嘅 transitive dep)。 + +- [x] **Step 2: 建 `src/schemas/post.ts`** + +```ts +import { z } from "zod"; + +export const postStatus = z.enum(["draft", "published"]); + +export const postInput = z.object({ + title: z.string().trim().min(1, "標題唔可以留空。"), + slug: z.string().trim().default(""), + excerpt: z.string().trim().default(""), + content: z.string().default(""), + coverImage: z.string().trim().default(""), + tags: z.string().trim().default(""), + metaDescription: z.string().trim().default(""), + status: postStatus.default("draft"), +}); + +export type PostInput = z.infer; +``` + +- [x] **Step 3: 建 `src/schemas/content.ts`** + +```ts +import { z } from "zod"; +import { CONTENT_KINDS } from "../db/schema"; + +export const contentItemInput = z.object({ + kind: z.enum(CONTENT_KINDS), + title: z.string().trim().min(1, "名稱唔可以留空。"), + description: z.string().default(""), + extra: z.string().trim().default(""), + status: z.enum(["draft", "published"]).default("published"), +}); + +export type ContentItemInput = z.infer; +``` + +- [x] **Step 4: 建 `src/schemas/case.ts`** + +```ts +import { z } from "zod"; + +export const caseInput = z.object({ + title: z.string().trim().min(1, "標題唔可以留空。"), + location: z.string().trim().default(""), + completedAt: z.string().trim().default(""), + description: z.string().default(""), + imageUrl: z.string().trim().default(""), + status: z.enum(["draft", "published"]).default("published"), +}); + +export type CaseInput = z.infer; +``` + +- [x] **Step 5: 建 `src/schemas/settings.ts`** + +```ts +import { z } from "zod"; +import { ALL_SETTING_KEYS } from "../data/settings-fields"; + +export const settingsInput = z.object( + Object.fromEntries( + ALL_SETTING_KEYS.map((key) => [key, z.string().trim().default("")]), + ), +); + +export type SettingsInput = z.infer; +``` + +- [x] **Step 6: 建 `src/schemas/ai.ts`** + +```ts +import { z } from "zod"; + +export const aiSettingsInput = z.object({ + ai_enabled: z.enum(["1", "0"]).default("1"), + ai_base_url: z.url("Base URL 要係有效網址。").default("https://api.deepinfra.com/v1/openai"), + ai_chat_model: z.string().trim().min(1, "請填模型名稱。").default("deepseek-ai/DeepSeek-V3-0324"), + ai_context_prompt: z.string().default(""), + ai_business_context: z.string().default(""), + ai_web_search_enabled: z.enum(["1", "0"]).default("0"), + ai_web_search_max_results: z.coerce.number().int().min(1).max(10).default(5), +}); + +export type AiSettingsInput = z.infer; + +export const aiGeneratedPost = z.object({ + title: z.string().trim().min(1), + content: z.string().trim().min(1), + metaDescription: z.string().trim().default(""), + excerpt: z.string().trim().default(""), +}); + +export type AiGeneratedPost = z.infer; +``` + +- [x] **Step 7: 建 `src/schemas/keyword.ts`** + +```ts +import { z } from "zod"; + +export const keywordStatus = z.enum(["pending", "generated", "skipped"]); + +export const keywordInput = z.object({ + keyword: z.string().trim().min(1, "關鍵字唔可以留空。"), +}); + +export type KeywordInput = z.infer; +``` + +- [x] **Step 8: 建 `src/schemas/index.ts`** + +```ts +export * from "./post"; +export * from "./content"; +export * from "./case"; +export * from "./settings"; +export * from "./ai"; +export * from "./keyword"; +``` + +- [x] **Step 9: 建 `src/lib/form.ts`** + +```ts +import type { ZodType } from "zod"; + +export type ParseResult = + | { ok: true; data: T } + | { ok: false; errors: Record }; + +/** 將 FormData 抽成物件再交畀 Zod 驗證;錯誤以 field -> 訊息 回傳。 */ +export function parseForm(form: FormData, schema: ZodType): ParseResult { + const raw: Record = {}; + for (const [key, value] of form.entries()) { + if (typeof value === "string") raw[key] = value; + } + + const result = schema.safeParse(raw); + if (result.success) return { ok: true, data: result.data }; + + const errors: Record = {}; + for (const issue of result.error.issues) { + const key = issue.path.join(".") || "_form"; + if (!errors[key]) errors[key] = issue.message; + } + return { ok: false, errors }; +} +``` + +- [x] **Step 10: 驗證** + +```bash +npm run build +``` + +Expected: build 成功(schemas 未有 callers,理應無 type error)。 + +--- + +## Task 2: 抽出 `uniqueSlug` + 遷移文章編輯頁 + +**Files:** +- Modify: `src/data/content.ts`(加 `uniqueSlug`) +- Modify: `src/pages/admin/post/[id].astro` + +- [x] **Step 1: 喺 `src/data/content.ts` 尾加 `uniqueSlug`** + +改 import 行(第 1 行)加入 `ne`: + +```ts +import { and, asc, desc, eq, ne } from "drizzle-orm"; +``` + +喺檔案最後加: + +```ts +/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */ +export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise { + 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()}`; +} +``` + +- [x] **Step 2: 改 `src/pages/admin/post/[id].astro` 的 imports** + +將第 2–7 行改成: + +```ts +import { eq } from "drizzle-orm"; +import AdminLayout from "../../../layouts/AdminLayout.astro"; +import { getDb } from "../../../lib/db"; +import { posts } from "../../../db/schema"; +import { slugify, autoExcerpt } from "../../../lib/markdown"; +import { getEnv } from "../../../lib/env"; +import { uniqueSlug } from "../../../data/content"; +import { parseForm } from "../../../lib/form"; +import { postInput } from "../../../schemas"; +``` + +(刪走原本嘅 `and, ne` 同本頁 local `uniqueSlug` 函式。) + +- [x] **Step 3: 刪走本頁 local `uniqueSlug`** + +移除原本第 25–42 行(`/** slug 撞咗就加 -2、-3… */ async function uniqueSlug(...)`)整個函式,改用 Task 2 Step 1 嘅共用版本(呼叫方式不變:`uniqueSlug(db, base, selfId)`)。 + +- [x] **Step 4: 改 error 變數同 POST 驗證邏輯** + +將 `let error = "";` 改成: + +```ts +let error = ""; +let errors: Record = {}; +``` + +將 POST 內 `if (action === "delete" ...)` 之後嘅整段驗證(原本第 53–100 行,由 `const title = ...` 到 redirect 前)換成: + +```ts + const parsed = parseForm(form, postInput); + if (!parsed.ok) { + errors = parsed.errors; + error = Object.values(parsed.errors)[0] ?? "輸入有誤。"; + } else { + const data = parsed.data; + const status = data.status; + const slug = await uniqueSlug( + db, + data.slug ? slugify(data.slug) : slugify(data.title), + isNew ? undefined : id, + ); + const excerpt = data.excerpt || autoExcerpt(data.content); + const metaDescription = data.metaDescription || excerpt.slice(0, 155); + const coverImage = data.coverImage || null; + const tags = data.tags || null; + const now = new Date(); + + if (isNew) { + await db.insert(posts).values({ + id: crypto.randomUUID(), + slug, + title: data.title, + excerpt, + content: data.content, + coverImage, + tags, + metaDescription, + focusKeyword: null, + status, + publishedAt: status === "published" ? now : null, + updatedAt: now, + }); + } else { + await db + .update(posts) + .set({ + slug, + title: data.title, + excerpt, + content: data.content, + coverImage, + tags, + metaDescription, + status, + publishedAt: existing!.publishedAt ?? (status === "published" ? now : null), + updatedAt: now, + }) + .where(eq(posts.id, id!)); + } + + return Astro.redirect("/admin"); + } +``` + +> 注意:`focusKeyword: null` 呢個欄位喺 Phase 2 Task 7 加;Phase 1 執行時先**唔好加**呢行,等 Phase 2 加咗 schema 先補返。Phase 1 版本省略 `focusKeyword`。 + +- [x] **Step 5: 表單加 per-field 錯誤顯示** + +將標題、slug、excerpt、coverImage、tags、metaDescription、content 每個 `