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
+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}`,
})),
});
}