Move seed.sql out of migrations and add SEO structured data
- Relocate seed.sql to scripts/ so wrangler d1 migrations apply doesn't treat it as a migration; update package.json, setup.mjs, README, and all AGENTS.md references. - Add src/lib/schema-org.ts builders (LocalBusiness, FAQPage, BlogPosting, BreadcrumbList) and emit JSON-LD via Base.astro. - Add lastmod to sitemap entries. - Set real site domain, worker route, and D1/KV ids. - Improve image loading hints and heading semantics across site components; switch BlogIndex to client:idle.
This commit is contained in:
+6
-6
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"pid": 98774,
|
||||
"port": 4321,
|
||||
"url": "http://localhost:4321",
|
||||
"pid": 52048,
|
||||
"port": 4322,
|
||||
"url": "http://localhost:4322",
|
||||
"urls": {
|
||||
"local": [
|
||||
"http://localhost:4321/"
|
||||
"http://localhost:4322/"
|
||||
],
|
||||
"network": [],
|
||||
"networkInterfaceNames": []
|
||||
},
|
||||
"background": true,
|
||||
"startedAt": "2026-09-11T08:49:14.193Z"
|
||||
"background": false,
|
||||
"startedAt": "2026-09-13T08:12:40.425Z"
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker
|
||||
- **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 是分開的**:`db:migrate:local` / `db:migrate` 只套 migrations,`migrations/seed.sql` 要用 `db:seed:local` / `db:seed` 另外執行;seed 可重複跑。
|
||||
- **Seed 與 migration 是分開的**:`db:migrate:local` / `db:migrate` 只套 migrations,`scripts/seed.sql` 要用 `db:seed:local` / `db:seed` 另外執行;seed 可重複跑。**`seed.sql` 必須放喺 `migrations/` 以外**,因為 `wrangler d1 migrations apply` 會將 `migrations_dir`(`wrangler.jsonc`,預設 `migrations`)內所有 `.sql` 當成 migration。
|
||||
- **表單驗證集中在 `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`;**預設供應商 DeepSeek 官方(`https://api.deepseek.com`、`deepseek-flash`)**,提示詞通用唔綁行業(角色/公司資料由後台 `ai_context_prompt`/`ai_business_context` 提供)。同步生成、一律存草稿並標記關鍵字 `generated`。需要 secrets `AI_API_KEY`(必需)/`TAVILY_API_KEY`(可選);未設時回明確錯誤,唔會 throw。已部署 DB 轉供應商要喺 `/admin/ai` 改,唔好重跑 `db:seed`(會覆蓋後台設定)。
|
||||
- **部署用 Cloudflare Workers Builds**(Git push 自動):build `npm run build`、deploy `npx wrangler deploy`。**Migration 唔會喺 CI 執行**,改 schema 要手動 `npm run db:migrate`(雲端)。
|
||||
@@ -121,7 +121,7 @@ When the user requests a durable behavior change, record it here or in the relev
|
||||
| `src/data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
|
||||
| `src/db/AGENTS.md` | Drizzle schema(migration 的唯一來源) |
|
||||
| `src/lib/AGENTS.md` | auth / env / db / form / media / ai / markdown 基礎工具 |
|
||||
| `migrations/AGENTS.md` | D1 migration 與 seed SQL |
|
||||
| `migrations/AGENTS.md` | D1 migration SQL(`migrations_dir` 內所有 `.sql` 都當 migration;seed 喺 `scripts/`) |
|
||||
| `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`、`scripts/`。
|
||||
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/`(含 `setup.mjs` 同 `seed.sql`)。
|
||||
|
||||
@@ -118,8 +118,9 @@ src/
|
||||
├─ lib/{auth,db,env,form,media,ai,markdown}.ts
|
||||
migrations/
|
||||
├─ 0000_init.sql … Drizzle migrations
|
||||
└─ seed.sql 種子資料(用 db:seed 另外執行)
|
||||
scripts/setup.mjs 一鍵 Cloudflare provision
|
||||
scripts/
|
||||
├─ setup.mjs 一鍵 Cloudflare provision
|
||||
└─ seed.sql 種子資料(用 db:seed 另外執行,唔可以放喺 migrations/)
|
||||
```
|
||||
|
||||
## 待客戶提供(可喺 `/admin/settings` 自行更新)
|
||||
|
||||
+11
-12
@@ -3,18 +3,17 @@ import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [react()],
|
||||
// ⚠️ 上線前改咗呢個做你嘅真域名(sitemap、canonical、OG 全部靠佢)
|
||||
site: "https://solar.develop-cat.com",
|
||||
|
||||
integrations: [react()],
|
||||
// ⚠️ 上線前改咗呢個做你嘅真域名(sitemap、canonical、OG 全部靠佢)
|
||||
site: "https://example.com",
|
||||
output: "static",
|
||||
|
||||
output: "static",
|
||||
|
||||
adapter: cloudflare({
|
||||
platformProxy: {
|
||||
enabled: true,
|
||||
// 本機 astro dev 時讀 wrangler.jsonc 嘅 bindings
|
||||
configPath: "./wrangler.jsonc",
|
||||
},
|
||||
}),
|
||||
adapter: cloudflare({
|
||||
platformProxy: {
|
||||
enabled: true,
|
||||
// 本機 astro dev 時讀 wrangler.jsonc 嘅 bindings
|
||||
configPath: "./wrangler.jsonc",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ 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 設定 + 示範關鍵字
|
||||
scripts/seed.sql 加 AI 設定 + 示範關鍵字
|
||||
.dev.vars.example 加 AI_API_KEY / TAVILY_API_KEY
|
||||
README.md / AGENTS.md 各層 同步文件
|
||||
```
|
||||
@@ -620,7 +620,7 @@ Expected: PASS。`npm run dev` → 提交一個空標題,見到紅色錯誤字
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/db/schema.ts`
|
||||
- Modify: `migrations/seed.sql`
|
||||
- Modify: `scripts/seed.sql`
|
||||
- Generate: `migrations/0002_*.sql`(由 drizzle-kit)
|
||||
|
||||
- [x] **Step 1: `posts` 加 `focusKeyword`**
|
||||
@@ -1399,7 +1399,7 @@ async function main() {
|
||||
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=./migrations/seed.sql`, { stdio: "inherit" });
|
||||
execSync(`npx wrangler d1 execute ${DB_NAME} --remote --file=./scripts/seed.sql`, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log("\n提示:上線前記得改 astro.config.mjs 嘅 site 做真域名。");
|
||||
|
||||
@@ -97,7 +97,7 @@ DeepSeek 思考模式**預設開啟**(effort `high`),會令:`temperature
|
||||
|---|---|
|
||||
| `src/lib/ai.ts` | 通用化 `buildSystemPrompt`;預設 baseUrl/model 改 DeepSeek;`callChat` 加 conditional thinking disabled、`max_tokens: 4000` |
|
||||
| `src/schemas/ai.ts` | `ai_base_url` / `ai_chat_model` 預設值同步 |
|
||||
| `migrations/seed.sql` | `ai_base_url` / `ai_chat_model` 改預設;`ai_business_context` 改通用格式範本 |
|
||||
| `scripts/seed.sql` | `ai_base_url` / `ai_chat_model` 改預設;`ai_business_context` 改通用格式範本 |
|
||||
| `src/pages/admin/ai.astro` | 模型/URL 預設值同步;公司背景+寫作風格加 placeholder/格式說明 |
|
||||
| `src/lib/AGENTS.md`、根 `AGENTS.md` | 更新 AI Blog 合約描述(DeepSeek 預設、通用提示詞) |
|
||||
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Cloudflare D1 的 schema migration 同種子資料。
|
||||
Cloudflare D1 的 schema migration SQL。
|
||||
|
||||
## Ownership
|
||||
|
||||
- 擁有 `migrations/` 全部 SQL、`migrations/meta/` 同 `seed.sql`。
|
||||
- 擁有 `migrations/` 全部 SQL 同 `migrations/meta/`(產生檔)。
|
||||
- Drizzle schema 定義喺 `src/db/schema.ts`(見 `src/db/AGENTS.md`)。
|
||||
- 種子資料 `seed.sql` 喺 `scripts/`(由 root AGENTS.md 擁有)。
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- **Migration 由工具產生**:`0000_*.sql`、`0001_*.sql`、`0002_*.sql` 由 `npm run db:generate`(drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。`0002_*.sql` 加咗 `posts.focus_keyword` 同 `blog_keywords` 表。
|
||||
- **`seed.sql` 係手寫、獨立於 migration**:全部用 `INSERT OR REPLACE`,可重複執行。`db:migrate` **唔會**執行 seed。
|
||||
- **`migrations/` 內所有 `.sql` 都會被當成 migration**:`wrangler d1 migrations apply` 會套用 `migrations_dir`(`wrangler.jsonc`,現為 `migrations`)內全部 `.sql`。所以**種子資料一定要放喺 `migrations/` 以外**(現時 `scripts/seed.sql`),否則 `db:migrate` 會連 seed 一齊種。
|
||||
- **Migration 唔會喺 CI 執行**:Cloudflare Workers Builds 只跑 build + deploy;改 schema 後要手動 `npm run db:migrate`(雲端)。
|
||||
- **指令**(本機用 `:local`,雲端省略):
|
||||
- `npm run db:generate` — 由 schema 產生 migration
|
||||
- `npm run db:migrate:local` / `npm run db:migrate` — 套用 migration
|
||||
- `npm run db:seed:local` / `npm run db:seed` — 匯入種子
|
||||
- **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`:service/feature/step/faq)、`cases`、一篇示範 `posts`、三個示範 `blog_keywords`(`pending`);另有 7 個 `ai_*` AI 設定 keys(獨立 seed block)。
|
||||
- DB 名稱 `yingfung-solar-db`,binding 喺 `wrangler.jsonc`(`database_id` 仍係佔位,部署前要填)。
|
||||
- `npm run db:seed:local` / `npm run db:seed` — 匯入種子(`scripts/seed.sql`)
|
||||
- **Seed 內容**(`scripts/seed.sql`,全部 `INSERT OR REPLACE`、可重複跑):公司資料(`site_settings`)、首頁內容(`content_items`:service/feature/step/faq)、`cases`、一篇示範 `posts`、三個示範 `blog_keywords`(`pending`);另有 7 個 `ai_*` AI 設定 keys(獨立 seed block)。
|
||||
- DB 名稱 `yingfung-solar-db`,binding 喺 `wrangler.jsonc`(`database_id` / KV `id` 由 `npm run setup` 或 `db:create` / `kv:create` 寫入)。
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- 改 schema → 先 `db:generate`,再 `db:migrate:local`;改 seed → 直接 `db:seed:local`。
|
||||
- 改 schema → 先 `db:generate`,再 `db:migrate:local`;改 seed → 直接改 `scripts/seed.sql` 再 `db:seed:local`。
|
||||
- 新增 setting key 時同步更新 `src/data/settings-fields.ts`。
|
||||
|
||||
## Verification
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "wrangler d1 migrations apply yingfung-solar-db --remote",
|
||||
"db:migrate:local": "wrangler d1 migrations apply yingfung-solar-db --local",
|
||||
"db:seed": "wrangler d1 execute yingfung-solar-db --remote --file=./migrations/seed.sql",
|
||||
"db:seed:local": "wrangler d1 execute yingfung-solar-db --local --file=./migrations/seed.sql",
|
||||
"db:seed": "wrangler d1 execute yingfung-solar-db --remote --file=./scripts/seed.sql",
|
||||
"db:seed:local": "wrangler d1 execute yingfung-solar-db --local --file=./scripts/seed.sql",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"kv:create": "wrangler kv namespace create CACHE",
|
||||
"r2:create": "wrangler r2 bucket create yingfung-solar-media",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const D1_PLACEHOLDER = "PASTE_D1_DATABASE_ID_HERE";
|
||||
const KV_PLACEHOLDER = "PASTE_KV_NAMESPACE_ID_HERE";
|
||||
const R2_BUCKET = "yingfung-solar-media";
|
||||
const WRANGLER = join(ROOT, "wrangler.jsonc");
|
||||
const SEED_FILE = join(ROOT, "migrations", "seed.sql");
|
||||
const SEED_FILE = join(ROOT, "scripts", "seed.sql");
|
||||
const ASTRO_CONFIG = join(ROOT, "astro.config.mjs");
|
||||
const DB_NAME = "yingfung-solar-db";
|
||||
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
|
||||
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
|
||||
- `theme/system.ts` — Chakra system(tokens / semanticTokens / fonts / globalCss),前台顏色同字體的唯一來源。現行設計:editorial 極簡風(暖米白 `#FAF8F4` 底、琥珀金 `brand` accent、墨黑 `ink`、Noto Serif HK 標題),詳見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`。
|
||||
- `layouts/Base.astro` — 前台 SEO head(canonical / OG / Twitter / sitemap link)+ Noto Sans HK。
|
||||
- `layouts/Base.astro` — 前台 SEO head(canonical / OG / Twitter / theme-color / JSON-LD `jsonLd` prop / sitemap link)+ Noto Sans HK(非阻塞載入,`media="print" onload` + `<noscript>` fallback)。
|
||||
- `layouts/AdminLayout.astro` — 後台外框,純 CSS、與前台一致的色系。
|
||||
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
|
||||
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`。
|
||||
@@ -31,12 +31,12 @@
|
||||
- 目前 SSR:`index.astro`、`blog/index.astro`、`blog/[slug].astro`、`admin/**`、`media/[...key].ts`、`sitemap.xml.ts`。
|
||||
- 保持靜態:`about.astro`、`robots.txt.ts`。
|
||||
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
|
||||
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island(`client:load`),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`。
|
||||
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island(`client:idle`,內容仍 SSR 出 HTML),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`。
|
||||
- **404**:`blog/[slug].astro` 找唔到文章 → 設 `Astro.response.status = 404` 並 render noindex 頁。
|
||||
- **Endpoint**:用 `APIRoute`;`sitemap.xml.ts` 由 D1 讀已發布文章並設 `s-maxage=3600`。
|
||||
- **Endpoint**:用 `APIRoute`;`sitemap.xml.ts` 由 D1 讀已發布文章(`updatedAt` 做 `<lastmod>`)並設 `s-maxage=3600`。
|
||||
- **圖片 endpoints**:`/admin/upload`(POST,受 middleware 保護,寫入 R2 `MEDIA`);`/media/[...key]`(GET,公開讀 R2 出圖,設 `Cache-Control: public, max-age=31536000, immutable` 同 ETag/304)。
|
||||
- **AI 生成(同步)**:`/admin/ai` 的生成喺 admin POST 內同步 `await` 完成先 redirect。如日後要改做背景處理,可用 `Astro.locals.cfContext`(Cloudflare `ExecutionContext`)嘅 `waitUntil`,唔使改現有流程。
|
||||
- **SEO**:用 `Base.astro`;canonical / OG / sitemap 全部靠 `astro.config.mjs` 的 `site`(現為佔位 `https://example.com`,上線前要改)。
|
||||
- **SEO**:用 `Base.astro`;canonical / OG / sitemap 全部靠 `astro.config.mjs` 的 `site`(現為佔位 `https://example.com`,上線前要改)。所有頁面都要有單一 `h1`,section 標題用 `h2`、項目用 `h3`,唔可以跳級(heading 語意一律用 Chakra `as`,唔可以淨靠字級)。JSON-LD 由 `lib/schema-org.ts` 砌,經 `Base.astro` 嘅 `jsonLd` prop 輸出:首頁 `LocalBusiness` + `FAQPage`、文章 `BlogPosting` + `BreadcrumbList`。
|
||||
|
||||
### Admin(`pages/admin/`)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
|
||||
- 新增首頁 section:建立元件 → 喺 `HomePage.tsx` 接入 → 資料由 `getHomeData`(`data/content.ts`)提供。
|
||||
- 新元件唔好各自建立 `Provider`,跟返現有最外層包法。
|
||||
- **Heading 語意**用 Chakra `as`:section 標題 `h2`、項目標題 `h3`,唔可以淨靠字級或 `Text` 冒充。FAQ 用 `<Heading as="h3" m="0">` 包住 `Accordion.ItemTrigger`(符合 heading > button),Process 步驟標題用 `Heading as="h3"`。
|
||||
- **圖片**:LCP(Hero)用 `loading="eager"` + `fetchPriority="high"`;其餘用 `loading="lazy"` + `decoding="async"`;一律保留 `aspectRatio` 防 CLS 並填 `alt`。
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ export function BlogIndex({ posts, settings }: { posts: BlogListItem[]; settings
|
||||
w="full"
|
||||
aspectRatio="16 / 10"
|
||||
objectFit="cover"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
transition="transform .5s"
|
||||
_groupHover={{ transform: "scale(1.03)" }}
|
||||
/>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BlogPost({ title, dateLabel, coverImage, html, settings }: Props
|
||||
</VStack>
|
||||
|
||||
{coverImage && (
|
||||
<Image src={coverImage} alt={title} w="full" mb="10" objectFit="cover" />
|
||||
<Image src={coverImage} alt={title} w="full" mb="10" objectFit="cover" loading="eager" decoding="async" />
|
||||
)}
|
||||
|
||||
<Box css={prose} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
|
||||
@@ -30,6 +30,8 @@ export function Cases({ items }: { items: CaseStudy[] }) {
|
||||
w="full"
|
||||
aspectRatio={{ base: "4 / 3", lg: "16 / 10" }}
|
||||
objectFit="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
transition="transform .5s"
|
||||
_groupHover={{ transform: "scale(1.03)" }}
|
||||
/>
|
||||
@@ -66,6 +68,8 @@ export function Cases({ items }: { items: CaseStudy[] }) {
|
||||
w="full"
|
||||
aspectRatio="4 / 3"
|
||||
objectFit="cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
transition="transform .5s"
|
||||
_groupHover={{ transform: "scale(1.03)" }}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Accordion, Box, Text } from "@chakra-ui/react";
|
||||
import { Accordion, Box, Heading, Text } from "@chakra-ui/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ContentItem } from "../../db/schema";
|
||||
import { Section } from "./Section";
|
||||
@@ -23,14 +23,16 @@ export function Faq({ items }: { items: ContentItem[] }) {
|
||||
borderColor="border.subtle"
|
||||
_last={{ borderBottomWidth: "1px" }}
|
||||
>
|
||||
<Accordion.ItemTrigger py="6" px="0" _hover={{ bg: "transparent" }}>
|
||||
<Text flex="1" textAlign="start" fontFamily="heading" fontWeight="700" color="ink" fontSize={{ base: "md", md: "lg" }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Accordion.ItemIndicator color="brand.700" _open={{ transform: "rotate(45deg)" }}>
|
||||
<Plus size={18} />
|
||||
</Accordion.ItemIndicator>
|
||||
</Accordion.ItemTrigger>
|
||||
<Heading as="h3" m="0" w="full" fontWeight="normal">
|
||||
<Accordion.ItemTrigger py="6" px="0" w="full" _hover={{ bg: "transparent" }}>
|
||||
<Text flex="1" textAlign="start" fontFamily="heading" fontWeight="700" color="ink" fontSize={{ base: "md", md: "lg" }}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Accordion.ItemIndicator color="brand.700" _open={{ transform: "rotate(45deg)" }}>
|
||||
<Plus size={18} />
|
||||
</Accordion.ItemIndicator>
|
||||
</Accordion.ItemTrigger>
|
||||
</Heading>
|
||||
<Accordion.ItemContent>
|
||||
<Accordion.ItemBody px="0" pb="7" pt="0" color="fg.muted" fontSize="sm" lineHeight="1.9" maxW="2xl">
|
||||
{item.description}
|
||||
|
||||
@@ -78,6 +78,9 @@ export function Hero({ settings }: { settings: Settings }) {
|
||||
w="full"
|
||||
aspectRatio="4 / 3"
|
||||
objectFit="cover"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
<SimpleGrid
|
||||
columns={3}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, SimpleGrid, Stack, Text } from "@chakra-ui/react";
|
||||
import { Box, Heading, SimpleGrid, Stack, Text } from "@chakra-ui/react";
|
||||
import type { ContentItem } from "../../db/schema";
|
||||
import { Section } from "./Section";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
@@ -29,9 +29,9 @@ export function Process({ items }: { items: ContentItem[] }) {
|
||||
>
|
||||
{item.extra}
|
||||
</Box>
|
||||
<Text fontFamily="heading" fontWeight="700" fontSize="lg" color="ink">
|
||||
<Heading as="h3" fontFamily="heading" fontWeight="700" fontSize="lg" color="ink" lineHeight="1.4">
|
||||
{item.title}
|
||||
</Text>
|
||||
</Heading>
|
||||
<Text fontSize="sm" color="fg.muted" lineHeight="1.75">
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`setting
|
||||
- **Slug 去重**:`uniqueSlug(db, base, selfId?)` 撞 slug 就加 `-2`、`-3`…;`selfId` 用喺更新自己時排除自己。admin 文章同 AI 生成都用。
|
||||
- **`Db` 型別**:`DrizzleD1Database<typeof schema>`。
|
||||
- **連結 helper**:`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
|
||||
- **設定欄位**:`settings-fields.ts` 的 `SETTINGS_GROUPS` 係 `/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `migrations/seed.sql`。
|
||||
- **設定欄位**:`settings-fields.ts` 的 `SETTINGS_GROUPS` 係 `/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `scripts/seed.sql`。
|
||||
- **圖片欄位**:`FieldType` 有 `"image"`;`hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
|
||||
- 顯示用文案繁體中文。
|
||||
|
||||
|
||||
+2
-2
@@ -16,13 +16,13 @@ Drizzle schema 定義,係 D1 migration 的唯一來源。
|
||||
- **狀態**:`posts` / `content_items` / `cases` 都有 `status`(`draft` | `published`);`blog_keywords` 用 `KEYWORD_STATUSES = ["pending", "generated", "skipped"]`(`KeywordStatus`)。
|
||||
- **AI 相關欄位**:`posts.focusKeyword`(生成時存焦點關鍵字);`blog_keywords` 為手動維護的關鍵字佇列,生成成功後 `status` 轉 `generated` 並記 `usedAt` / `postId`。
|
||||
- **型別**:由 `$inferSelect` / `$inferInsert` 匯出(`Post`、`ContentItem`、`CaseStudy`、`SiteSetting`、`BlogKeyword`、`NewBlogKeyword` 等),其他地方重用呢啲型別。
|
||||
- **改 schema 流程**:改 `schema.ts` → `npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql`(`seed.sql` 除外)。
|
||||
- **改 schema 流程**:改 `schema.ts` → `npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql`(種子資料喺 `scripts/seed.sql`,可以改)。
|
||||
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)`、`idx_keywords_status(status)`)。
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- 加欄位時考慮預設值同 nullability,因為 migration 會套用到已有資料。
|
||||
- 改完 schema 記得同步 `migrations/seed.sql` 同 admin 表單(`pages/admin`)。
|
||||
- 改完 schema 記得同步 `scripts/seed.sql` 同 admin 表單(`pages/admin`)。
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+22
-2
@@ -8,6 +8,7 @@ interface Props {
|
||||
ogType?: "website" | "article";
|
||||
publishedTime?: Date | null;
|
||||
image?: string;
|
||||
jsonLd?: object | null | (object | null)[];
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -17,6 +18,7 @@ const {
|
||||
ogType = "website",
|
||||
publishedTime = null,
|
||||
image,
|
||||
jsonLd = null,
|
||||
} = Astro.props;
|
||||
|
||||
const siteName = SITE_NAME;
|
||||
@@ -24,6 +26,11 @@ const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
|
||||
const canonical = new URL(Astro.url.pathname, origin + "/").toString();
|
||||
const fullTitle = title.includes(siteName) ? title : `${title} — ${siteName}`;
|
||||
const ogImage = image ? (image.startsWith("http") ? image : new URL(image, origin + "/").toString()) : undefined;
|
||||
|
||||
const jsonLdItems = (Array.isArray(jsonLd) ? jsonLd : [jsonLd]).filter((item): item is object => item != null);
|
||||
const jsonLdHtml = jsonLdItems.length
|
||||
? JSON.stringify(jsonLdItems.length === 1 ? jsonLdItems[0] : jsonLdItems).replace(/</g, "\\u003c")
|
||||
: null;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -38,16 +45,21 @@ const ogImage = image ? (image.startsWith("http") ? image : new URL(image, origi
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content="zh_HK" />
|
||||
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||
{ogImage && <meta property="og:image:alt" content={title} />}
|
||||
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||
{ogImage && <meta name="twitter:image:alt" content={title} />}
|
||||
<meta name="theme-color" content="#FAF8F4" />
|
||||
|
||||
{jsonLdHtml && <script type="application/ld+json" is:inline set:html={jsonLdHtml} />}
|
||||
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -55,7 +67,15 @@ const ogImage = image ? (image.startsWith("http") ? image : new URL(image, origi
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@400;500;700;900&family=Noto+Serif+HK:wght@600;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@400;500;700;900&family=Noto+Serif+HK:wght@600;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
|
||||
+3
-2
@@ -2,11 +2,11 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理、媒體 URL/R2 清理。
|
||||
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理、媒體 URL/R2 清理、schema.org JSON-LD。
|
||||
|
||||
## Ownership
|
||||
|
||||
- 擁有 `src/lib/` 七個檔案:`env.ts`、`db.ts`、`auth.ts`、`form.ts`、`ai.ts`、`markdown.ts`、`media.ts`。
|
||||
- 擁有 `src/lib/` 八個檔案:`env.ts`、`db.ts`、`auth.ts`、`form.ts`、`ai.ts`、`markdown.ts`、`media.ts`、`schema-org.ts`。
|
||||
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
|
||||
|
||||
## Local Contracts
|
||||
@@ -27,6 +27,7 @@
|
||||
- `generateBlogPost(db, env, input)` 同步生成:`{ mode: "topic" }` 或 `{ mode: "next" }`(攞佇列下一個 pending);一律存 **draft** 並將關鍵字標記 `generated`。缺 `AI_API_KEY` / 未啟用時回 `{ ok: false, error }`,唔會 throw。
|
||||
- **`markdown.ts`**:`renderMarkdown`(`marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`。
|
||||
- **`media.ts`**:媒體 URL 同 R2 清理。`MEDIA_PREFIX = "/media/"`、`mediaUrl(key)` 砌對外 URL、`mediaKey(url)` 由 `/media/...` 取 R2 key(唔係前綴回 `null`)、`deleteMedia(env, url)` 刪除對應 R2 物件(外部 URL/空值唔理,失敗唔 throw)。
|
||||
- **`schema-org.ts`**:schema.org JSON-LD 建構器,只砌資料、唔負責輸出(輸出喺 `Base.astro` 嘅 `jsonLd` prop)。匯出 `localBusiness(settings, origin)`、`faqPage(items)`(無題目回 `null`)、`blogPosting(post, settings, origin)`、`breadcrumbs(origin, trail)`。所有欄位 optional,冇資料就唔加,避免空值 schema。
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Settings } from "../data/content";
|
||||
import type { ContentItem, Post } from "../db/schema";
|
||||
|
||||
/**
|
||||
* schema.org (JSON-LD) 建構器。
|
||||
* 只負責砌資料,唔負責輸出;輸出喺 Base.astro 嘅 jsonLd prop。
|
||||
* 所有欄位都係 optional,冇資料就唔加,避免出現空值 schema。
|
||||
*/
|
||||
|
||||
function withContext<T extends Record<string, unknown>>(obj: T): T & { "@context": string } {
|
||||
return { "@context": "https://schema.org", ...obj };
|
||||
}
|
||||
|
||||
function companyName(settings: Settings): string {
|
||||
return settings.company_name || settings.company_short_name || "盈豐太陽能工程有限公司";
|
||||
}
|
||||
|
||||
/** 本地商戶(首頁)。 */
|
||||
export function localBusiness(settings: Settings, origin: string) {
|
||||
const sameAs = settings.facebook_url ? [settings.facebook_url] : undefined;
|
||||
return withContext({
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${origin}/#business`,
|
||||
name: companyName(settings),
|
||||
url: `${origin}/`,
|
||||
...(settings.seo_default_description ? { description: settings.seo_default_description } : {}),
|
||||
...(settings.phone ? { telephone: settings.phone } : {}),
|
||||
...(settings.email ? { email: settings.email } : {}),
|
||||
...(settings.address
|
||||
? { address: { "@type": "PostalAddress", streetAddress: settings.address, addressCountry: "HK" } }
|
||||
: {}),
|
||||
...(settings.og_image ? { image: settings.og_image } : {}),
|
||||
...(sameAs ? { sameAs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 常見問題(首頁 FAQ)。冇問題就唔輸出。 */
|
||||
export function faqPage(items: ContentItem[]) {
|
||||
if (items.length === 0) return null;
|
||||
return withContext({
|
||||
"@type": "FAQPage",
|
||||
mainEntity: items.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.title,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.description },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Blog 文章。 */
|
||||
export function blogPosting(post: Post, settings: Settings, origin: string) {
|
||||
const publisher = companyName(settings);
|
||||
const url = `${origin}/blog/${post.slug}`;
|
||||
return withContext({
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
url,
|
||||
mainEntityOfPage: url,
|
||||
...(post.metaDescription || post.excerpt ? { description: post.metaDescription || post.excerpt } : {}),
|
||||
...(post.publishedAt ? { datePublished: post.publishedAt.toISOString() } : {}),
|
||||
dateModified: (post.updatedAt ?? post.publishedAt ?? new Date()).toISOString(),
|
||||
...(post.coverImage ? { image: post.coverImage } : {}),
|
||||
author: { "@type": "Organization", name: publisher },
|
||||
publisher: { "@type": "Organization", name: publisher },
|
||||
});
|
||||
}
|
||||
|
||||
/** 麵包屑。trail 由外至內,path 以 "/" 開頭。 */
|
||||
export function breadcrumbs(origin: string, trail: { name: string; path: string }[]) {
|
||||
return withContext({
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: trail.map((item, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: item.name,
|
||||
item: `${origin}${item.path}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { formatDate, getPostBySlug, getSettings } from "../../data/content";
|
||||
import { getDb } from "../../lib/db";
|
||||
import { renderMarkdown } from "../../lib/markdown";
|
||||
import { getEnv } from "../../lib/env";
|
||||
import { blogPosting, breadcrumbs } from "../../lib/schema-org";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -18,6 +19,17 @@ if (!post) {
|
||||
|
||||
const html = post ? renderMarkdown(post.content) : "";
|
||||
const description = post?.metaDescription || post?.excerpt || "";
|
||||
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
|
||||
const jsonLd = post
|
||||
? [
|
||||
blogPosting(post, settings, origin),
|
||||
breadcrumbs(origin, [
|
||||
{ name: "首頁", path: "/" },
|
||||
{ name: "Blog", path: "/blog" },
|
||||
{ name: post.title, path: `/blog/${post.slug}` },
|
||||
]),
|
||||
]
|
||||
: null;
|
||||
---
|
||||
|
||||
{
|
||||
@@ -28,6 +40,7 @@ const description = post?.metaDescription || post?.excerpt || "";
|
||||
ogType="article"
|
||||
publishedTime={post.publishedAt}
|
||||
image={post.coverImage ?? undefined}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<BlogPost
|
||||
title={post.title}
|
||||
@@ -35,7 +48,7 @@ const description = post?.metaDescription || post?.excerpt || "";
|
||||
coverImage={post.coverImage}
|
||||
html={html}
|
||||
settings={settings}
|
||||
client:load
|
||||
client:idle
|
||||
/>
|
||||
</Base>
|
||||
) : (
|
||||
|
||||
@@ -26,5 +26,5 @@ Astro.response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-re
|
||||
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
|
||||
image={settings.og_image}
|
||||
>
|
||||
<BlogIndex posts={items} settings={settings} client:load />
|
||||
<BlogIndex posts={items} settings={settings} client:idle />
|
||||
</Base>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HomePage } from "../components/site/HomePage";
|
||||
import { getHomeData } from "../data/content";
|
||||
import { getDb } from "../lib/db";
|
||||
import { getEnv } from "../lib/env";
|
||||
import { faqPage, localBusiness } from "../lib/schema-org";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -11,6 +12,9 @@ const db = getDb(getEnv().DB);
|
||||
const data = await getHomeData(db);
|
||||
|
||||
const settings = data.settings;
|
||||
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
|
||||
const jsonLd = [localBusiness(settings, origin), faqPage(data.faqs)];
|
||||
|
||||
Astro.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, s-maxage=60, stale-while-revalidate=300",
|
||||
@@ -21,6 +25,7 @@ Astro.response.headers.set(
|
||||
title={settings.seo_default_title || "香港村屋太陽能一站式服務"}
|
||||
description={settings.seo_default_description}
|
||||
image={settings.og_image}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<HomePage data={data} client:load />
|
||||
<HomePage data={data} client:idle />
|
||||
</Base>
|
||||
|
||||
@@ -16,11 +16,19 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
.where(eq(posts.status, "published"))
|
||||
.orderBy(desc(posts.publishedAt));
|
||||
|
||||
const urls = ["/", "/blog", ...list.map((p) => `/blog/${p.slug}`)];
|
||||
const entries = [
|
||||
{ loc: "/", lastmod: null as Date | null },
|
||||
{ loc: "/blog", lastmod: list[0]?.updatedAt ?? null },
|
||||
...list.map((p) => ({ loc: `/blog/${p.slug}`, lastmod: p.updatedAt })),
|
||||
];
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((u) => ` <url><loc>${origin}${u}</loc></url>`).join("\n")}
|
||||
${entries
|
||||
.map(({ loc, lastmod }) =>
|
||||
` <url><loc>${origin}${loc}</loc>${lastmod ? `<lastmod>${new Date(lastmod).toISOString()}</lastmod>` : ""}</url>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
|
||||
+49
-42
@@ -1,43 +1,50 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "yingfung-solar",
|
||||
"main": "@astrojs/cloudflare/entrypoints/server",
|
||||
"compatibility_date": "2026-09-10",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "404-page"
|
||||
},
|
||||
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"logs": { "enabled": true }
|
||||
},
|
||||
|
||||
"vars": {
|
||||
"SITE_NAME": "盈豐太陽能"
|
||||
},
|
||||
|
||||
// ---- 以下 id 要由 `npm run db:create` / `npm run kv:create` 產生後填上 ----
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "yingfung-solar-db",
|
||||
"database_id": "PASTE_D1_DATABASE_ID_HERE"
|
||||
}
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "CACHE",
|
||||
"id": "PASTE_KV_NAMESPACE_ID_HERE"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "yingfung-solar-media"
|
||||
}
|
||||
]
|
||||
}
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "yingfung-solar",
|
||||
"main": "@astrojs/cloudflare/entrypoints/server",
|
||||
"compatibility_date": "2026-09-10",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "solar.develop-cat.com",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "404-page",
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"logs": {
|
||||
"enabled": true
|
||||
},
|
||||
},
|
||||
"vars": {
|
||||
"SITE_NAME": "盈豐太陽能",
|
||||
},
|
||||
// ---- 以下 id 要由 `npm run db:create` / `npm run kv:create` 產生後填上 ----
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "yingfung-solar-db",
|
||||
"database_id": "fcbfc131-2c04-42ca-b1d7-3dbbe1c030cd",
|
||||
"migrations_dir": "migrations",
|
||||
},
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "CACHE",
|
||||
"id": "475b4f68bc4244188f353e425388152d",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "yingfung-solar-media",
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user