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:
+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, {
|
||||
|
||||
Reference in New Issue
Block a user