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:
+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}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user