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:
@@ -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")同 TOML(key = "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);
|
||||
});
|
||||
Reference in New Issue
Block a user