Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion website/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions website/src/lib/og-routes.ts
Original file line number Diff line number Diff line change
@@ -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();
}
20 changes: 18 additions & 2 deletions website/src/lib/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─── */

Expand All @@ -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';
Expand Down Expand Up @@ -129,8 +133,12 @@ export function loadFonts(allText: string): FontsResult {

export async function collectPages(): Promise<Record<string, PageMeta>> {
const posts = await getCollection('posts');
const docs = await getCollection('docs');
const pages: Record<string, PageMeta> = {};

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] = {
Expand All @@ -139,14 +147,22 @@ export async function collectPages(): Promise<Record<string, PageMeta>> {
};
}

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;
Expand Down
76 changes: 76 additions & 0 deletions website/src/routeData.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }>,
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);
});