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
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(SCRIPT_DIR);
const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
const WRANGLER = join(ROOT, "wrangler.jsonc");
const SEED_FILE = join(ROOT, "migrations", "seed.sql");
const ASTRO_CONFIG = join(ROOT, "astro.config.mjs");
const DB_NAME = "yingfung-solar-db";
const rl = createInterface({ input, output });
function run(cmd) {
return execSync(cmd, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
}
function runSoft(cmd) {
try {
return { ok: true, out: run(cmd), err: "" };
} catch (err) {
return { ok: false, out: err.stdout ?? err.stderr ?? "", err: err.stderr ?? "" };
}
}
/** 由 wrangler 輸出抽 id;同時支援 JSON"key": "value")同 TOMLkey = "value")。 */
function extractId(out, keys) {
for (const key of keys) {
const re = new RegExp(`(?<!\\w)"?${key}"?\\s*[=:]\\s*"([0-9a-f-]+)"`, "i");
const m = out.match(re);
if (m) return m[1];
}
return null;
}
/** 由 wrangler 嘅 JSON 陣列輸出搵第一個符合條件嘅項目。 */
function findInJsonArray(text, predicate) {
try {
const arr = JSON.parse(text);
if (Array.isArray(arr)) return arr.find(predicate) ?? null;
} catch {
// 唔係 JSON 就當搵唔到。
}
return null;
}
async function main() {
console.log("\n=== 盈豐太陽能 — Cloudflare 一鍵設定 ===\n");
console.log("檢查 wrangler 登入狀態…");
const who = runSoft("npx wrangler whoami --json");
const whoText = `${who.out}\n${who.err}`;
if (!who.ok || /not authenticated|not logged in/i.test(whoText)) {
console.error("未登入 Cloudflare。請先執行: npm run login");
process.exit(1);
}
let config = readFileSync(WRANGLER, "utf8");
if (config.includes(D1_PLACEHOLDER)) {
console.log(`建立 D1 database「${DB_NAME}」…`);
const res = runSoft(`npx wrangler d1 create ${DB_NAME}`);
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["database_id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 database_id…");
const list = runSoft("npx wrangler d1 list --json");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.name === DB_NAME);
id = found?.uuid ?? null;
}
if (!id) {
console.error("建立 / 查詢 D1 database 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(D1_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` database_id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("D1 id 已設定,略過。");
}
if (config.includes(KV_PLACEHOLDER)) {
console.log("建立 KV namespace「CACHE」…");
const res = runSoft("npx wrangler kv namespace create CACHE");
let out = res.out || res.err || "";
let id = res.ok ? extractId(out, ["id"]) : null;
if (!res.ok) {
console.log(" 建立失敗,可能已經存在;嘗試查出現有 KV id…");
const list = runSoft("npx wrangler kv namespace list");
out = `${out}\n${list.out || list.err || ""}`;
const found = findInJsonArray(list.out, (r) => r.title === "CACHE");
id = found?.id ?? null;
}
if (!id) {
console.error("建立 / 查詢 KV namespace 失敗。以下係 wrangler 輸出:");
console.error(out);
process.exit(1);
}
config = config.replace(KV_PLACEHOLDER, () => id);
writeFileSync(WRANGLER, config, "utf8");
console.log(` kv id = ${id}(已寫入 wrangler.jsonc`);
} else {
console.log("KV id 已設定,略過。");
}
const setPassword = await rl.question("而家設定後台密碼 ADMIN_PASSWORD(y/N)");
if (setPassword.trim().toLowerCase() === "y") {
execSync("npx wrangler secret put ADMIN_PASSWORD", { stdio: "inherit" });
console.log("ADMIN_PASSWORD 已處理。");
} else {
console.log("略過 ADMIN_PASSWORD。");
}
const migrate = await rl.question("而家套用雲端 migration + seed(y/N)");
if (migrate.trim().toLowerCase() === "y") {
execSync(`npx wrangler d1 migrations apply ${DB_NAME} --remote`, { stdio: "inherit" });
execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file="${SEED_FILE}"`, { stdio: "inherit" });
}
let siteReady = true;
try {
if (readFileSync(ASTRO_CONFIG, "utf8").includes("https://example.com")) {
siteReady = false;
console.log("\n⚠️ 警告:astro.config.mjs 嘅 site 仍然係 https://example.com。");
console.log(" 上線前必須改成真域名,否則 sitemap / canonical / OG 會出錯。");
console.log(" 已跳過 build + deploy;更新 site 後請自行執行: npm run deploy\n");
}
} catch {
// 讀唔到 astro.config.mjs 就當冇問題,繼續。
}
if (siteReady) {
const deploy = await rl.question("而家 build + deploy(y/N)");
if (deploy.trim().toLowerCase() === "y") {
execSync("npm run build", { stdio: "inherit" });
execSync("npx wrangler deploy", { stdio: "inherit" });
}
}
console.log("\n完成。\n");
rl.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});