diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 407cb23e..7be12193 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -44,6 +44,7 @@ export default defineConfig({ starlight({ title: 'Bub', description: 'A common shape for agents that live alongside people.', + routeMiddleware: './src/routeData.ts', expressiveCode: false, logo: { light: './src/assets/bub-logo.png', diff --git a/website/src/content.config.ts b/website/src/content.config.ts index 5c1b4d38..88d3ffce 100644 --- a/website/src/content.config.ts +++ b/website/src/content.config.ts @@ -27,7 +27,15 @@ const userwall = defineCollection({ }); export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + docs: defineCollection({ + loader: docsLoader(), + schema: docsSchema({ + extend: ({ image }) => + z.object({ + cover: image().optional(), + }), + }), + }), i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }), posts, userwall, diff --git a/website/src/lib/og-routes.ts b/website/src/lib/og-routes.ts new file mode 100644 index 00000000..4c57370c --- /dev/null +++ b/website/src/lib/og-routes.ts @@ -0,0 +1,20 @@ +const INDEX_ROUTE_PATTERN = /(?:^|\/)index$/; + +function trimRoute(route: string): string { + return route.replace(/^\/+|\/+$/g, ''); +} + +export function normalizeOgRouteFromPathname(pathname: string): string { + return trimRoute(pathname) || 'index'; +} + +export function normalizeOgRouteFromEntryId(id: string): string { + // Strip the source file extension and collapse trailing `/index` docs files + // onto their directory route so content entry IDs match page URLs. + const route = id.replace(/\.(md|mdx)$/, '').replace(INDEX_ROUTE_PATTERN, ''); + return normalizeOgRouteFromPathname(route); +} + +export function createOgImageUrl(route: string, site: URL): string { + return new URL(`/og/${route}.png`, site).toString(); +} diff --git a/website/src/lib/og.ts b/website/src/lib/og.ts index 5e4b2f15..55539064 100644 --- a/website/src/lib/og.ts +++ b/website/src/lib/og.ts @@ -11,6 +11,7 @@ import sharp from 'sharp'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { getCollection } from 'astro:content'; +import { normalizeOgRouteFromEntryId } from '@/lib/og-routes'; /* ─── Types ─── */ @@ -32,6 +33,9 @@ export interface FontsResult { fontFamily: string; } +const SITE_DESCRIPTION = 'A common shape for agents that live alongside people.'; +const ZH_CN_SITE_DESCRIPTION = '与 Human 同在的轻量级 Agent 运行时。'; + /* ─── Font loading (@fontsource packages) ─── */ const OUTFIT_DIR = 'node_modules/@fontsource/outfit/files'; @@ -129,8 +133,12 @@ export function loadFonts(allText: string): FontsResult { export async function collectPages(): Promise> { const posts = await getCollection('posts'); + const docs = await getCollection('docs'); const pages: Record = {}; + const defaultDescription = (route: string) => + route.startsWith('zh-cn/') ? ZH_CN_SITE_DESCRIPTION : SITE_DESCRIPTION; + for (const post of posts) { const route = `posts/${post.id.replace(/\.md$/, '')}`; pages[route] = { @@ -139,14 +147,22 @@ export async function collectPages(): Promise> { }; } + for (const doc of docs) { + const route = normalizeOgRouteFromEntryId(doc.id); + pages[route] = { + title: doc.data.title, + description: doc.data.description ?? defaultDescription(route), + }; + } + pages['index'] = { title: 'Bub', - description: 'A common shape for agents that live alongside people.', + description: SITE_DESCRIPTION, }; pages['zh-cn/index'] = { title: 'Bub', - description: '与 Human 同在的轻量级 Agent 运行时。', + description: ZH_CN_SITE_DESCRIPTION, }; return pages; diff --git a/website/src/routeData.ts b/website/src/routeData.ts new file mode 100644 index 00000000..d0e05b31 --- /dev/null +++ b/website/src/routeData.ts @@ -0,0 +1,76 @@ +import { defineRouteMiddleware } from '@astrojs/starlight/route-data'; +import { createOgImageUrl, normalizeOgRouteFromPathname } from '@/lib/og-routes'; + +interface OgImageMeta { + url: string; + width?: string; + height?: string; +} + +interface CoverImage { + src: string; + width?: number | string; + height?: number | string; +} + +interface RouteDataContext { + locals: { starlightRoute: { entry: { data: { cover?: CoverImage } } } }; + site: URL; + url: URL; +} + +function upsertMetaTag( + head: Array<{ tag: string; attrs?: Record }>, + key: 'property' | 'name', + value: string, + content: string, +) { + const existingTag = head.find((entry) => entry.tag === 'meta' && entry.attrs?.[key] === value); + + if (existingTag) { + existingTag.attrs = { ...existingTag.attrs, content }; + return; + } + + head.push({ + tag: 'meta', + attrs: { [key]: value, content }, + }); +} + +function normalizeDimension(value: unknown): string | undefined { + return typeof value === 'number' || typeof value === 'string' ? String(value) : undefined; +} + +function getDocOgImage(context: RouteDataContext): OgImageMeta { + const cover = context.locals.starlightRoute.entry.data.cover; + + if (cover) { + return { + url: new URL(cover.src, context.site).toString(), + width: normalizeDimension(cover.width), + height: normalizeDimension(cover.height), + }; + } + + const route = normalizeOgRouteFromPathname(context.url.pathname); + return { + url: createOgImageUrl(route, context.site), + width: '1200', + height: '630', + }; +} + +export const onRequest = defineRouteMiddleware((context) => { + if (!context.site) { + throw new Error('Astro site URL must be configured for docs OG image metadata.'); + } + + const image = getDocOgImage(context); + const { head } = context.locals.starlightRoute; + + upsertMetaTag(head, 'property', 'og:image', image.url); + if (image.width) upsertMetaTag(head, 'property', 'og:image:width', image.width); + if (image.height) upsertMetaTag(head, 'property', 'og:image:height', image.height); + upsertMetaTag(head, 'name', 'twitter:image', image.url); +});