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:
2026-09-13 16:21:59 +08:00
parent f6b885efe6
commit 6dedb5469f
28 changed files with 255 additions and 108 deletions
+6 -6
View File
@@ -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"
}
+3 -3
View File
@@ -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 schemamigration 的唯一來源) |
| `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` 都當 migrationseed 喺 `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`
+3 -2
View File
@@ -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` 自行更新)
+1 -2
View File
@@ -3,10 +3,9 @@ import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
export default defineConfig({
integrations: [react()],
// ⚠️ 上線前改咗呢個做你嘅真域名(sitemap、canonical、OG 全部靠佢)
site: "https://example.com",
site: "https://solar.develop-cat.com",
output: "static",
@@ -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 預設、通用提示詞) |
+8 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -8,7 +8,7 @@
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
- `theme/system.ts` — Chakra systemtokens / 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 headcanonical / OG / Twitter / sitemap link)+ Noto Sans HK
- `layouts/Base.astro` — 前台 SEO headcanonical / 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` 同 ETag304)。
- **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/`
+2
View File
@@ -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"`
- **圖片**LCPHero)用 `loading="eager"` + `fetchPriority="high"`;其餘用 `loading="lazy"` + `decoding="async"`;一律保留 `aspectRatio` 防 CLS 並填 `alt`
## Verification
+2
View File
@@ -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)" }}
/>
+1 -1
View File
@@ -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 }} />
+4
View File
@@ -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)" }}
/>
+4 -2
View File
@@ -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,7 +23,8 @@ export function Faq({ items }: { items: ContentItem[] }) {
borderColor="border.subtle"
_last={{ borderBottomWidth: "1px" }}
>
<Accordion.ItemTrigger py="6" px="0" _hover={{ bg: "transparent" }}>
<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>
@@ -31,6 +32,7 @@ export function Faq({ items }: { items: ContentItem[] }) {
<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}
+3
View File
@@ -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}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -2,11 +2,11 @@
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理、媒體 URLR2 清理。
跨層基礎工具:環境變數、DB 連線、後台認證、表單驗證、AI 生成、Markdown 處理、媒體 URLR2 清理、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
+79
View File
@@ -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}`,
})),
});
}
+14 -1
View File
@@ -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>
) : (
+1 -1
View File
@@ -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>
+6 -1
View File
@@ -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>
+10 -2
View File
@@ -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, {
+22 -15
View File
@@ -3,41 +3,48 @@
"name": "yingfung-solar",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-09-10",
"compatibility_flags": ["nodejs_compat"],
"compatibility_flags": [
"nodejs_compat"
],
"routes": [
{
"pattern": "solar.develop-cat.com",
"custom_domain": true
}
],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
"not_found_handling": "404-page",
},
"observability": {
"enabled": true,
"logs": { "enabled": true }
"logs": {
"enabled": true
},
},
"vars": {
"SITE_NAME": "盈豐太陽能"
"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"
}
"database_id": "fcbfc131-2c04-42ca-b1d7-3dbbe1c030cd",
"migrations_dir": "migrations",
},
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "PASTE_KV_NAMESPACE_ID_HERE"
}
"id": "475b4f68bc4244188f353e425388152d",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
"bucket_name": "yingfung-solar-media",
},
],
}