feat(web): Open Graph images for the landing page and docs pages

The site card shows BBCA's KSEI ownership split under the tagline; each docs
page gets a card with its section, title and description. Cards are rendered
at build time with satori and sharp from the bundled Plex and JetBrains Mono
fonts, and a Starlight route middleware points og:image at them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ep2qFFvuFEnBvLpoLxxdEi
This commit is contained in:
hermes 2026-10-15 06:32:14 +00:00
commit 0859c00020
9 changed files with 248 additions and 1 deletions

150
web/src/og/cards.ts Normal file
View file

@ -0,0 +1,150 @@
// Open Graph cards (1200x630 PNG), rendered at build time. satori lays out the
// card and turns its text into SVG paths, so sharp rasterizes it without any
// system fonts.
import satori from 'satori';
import sharp from 'sharp';
import plex400 from '@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff?inline';
import plex600 from '@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-600-normal.woff?inline';
import mono400 from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff?inline';
import mono700 from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff?inline';
const W = 1200;
const H = 630;
const PAD = 72;
const c = {
bg: '#0d0e10',
surface: '#16181c',
line: '#2c3036',
text: '#e8eaed',
soft: '#c3c8cf',
muted: '#9aa1ab',
dim: '#6f7782',
accent: '#ce1126',
link: '#ff6b6f',
};
const sans = 'IBM Plex Sans';
const mono = 'JetBrains Mono';
const font = (dataUrl: string) => Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
const fonts = [
{ name: sans, data: font(plex400), weight: 400 as const },
{ name: sans, data: font(plex600), weight: 600 as const },
{ name: mono, data: font(mono400), weight: 400 as const },
{ name: mono, data: font(mono700), weight: 700 as const },
];
type Style = Record<string, string | number>;
type Node = { type: string; props: { style?: Style; children?: Child | Child[] } };
type Child = Node | string;
const h = (type: string, style: Style, ...children: Child[]): Node => ({
type,
props: { style: { display: 'flex', ...style }, children },
});
async function png(root: Node): Promise<Buffer> {
const svg = await satori(root as never, { width: W, height: H, fonts });
return sharp(Buffer.from(svg)).png().toBuffer();
}
const wordmark = h(
'div',
{ alignItems: 'center', gap: 4, fontFamily: mono, fontWeight: 700, fontSize: 40, color: c.text },
'idx',
h('div', { width: 17, height: 36, background: c.accent }),
);
const footer = (left: string, right?: string) =>
h(
'div',
{ justifyContent: 'space-between', fontFamily: mono, fontSize: 22, color: c.dim },
h('div', {}, left),
h('div', {}, right ?? ''),
);
const frame = (...children: Child[]) =>
h(
'div',
{ width: W, height: H, background: c.bg, color: c.text },
h('div', { position: 'absolute', top: 64, right: PAD, bottom: 52, left: PAD, flexDirection: 'column' }, ...children),
);
// BBCA from the 2026-09-30 KSEI snapshot, the same capture as the landing page's
// ownership spotlight (src/pages/index.astro). Refresh both together.
const spotlight = {
ticker: 'BBCA',
snapshot: '2026-09-30',
top: { name: 'DWIMURIA INVESTAMA ANDALAN', pct: 54.94 },
others: [1.15, 1.07, 1.02],
freeFloat: 41.82,
};
/** The site card: the tagline over BBCA's ownership split. */
export function siteCard(): Promise<Buffer> {
const s = spotlight;
const othersPct = s.others.reduce((a, b) => a + b, 0);
const gap = 3;
const barW = W - 2 * PAD - gap * (s.others.length + 1);
const seg = (pct: number) => (barW * pct) / 100;
const otherShades = [c.soft, c.muted, c.dim];
const label = (title: string, value: string, style: Style = {}) =>
h(
'div',
{ flexDirection: 'column', gap: 4, fontFamily: mono, ...style },
h('div', { fontSize: 20, color: c.muted }, title),
h('div', { fontSize: 28, fontWeight: 700, color: c.text }, value),
);
return png(
frame(
h('div', { justifyContent: 'space-between', alignItems: 'center' }, wordmark, h('div', { fontFamily: mono, fontSize: 20, color: c.dim }, `KSEI snapshot ${s.snapshot}`)),
h(
'div',
{ flexDirection: 'column', marginTop: 36, fontFamily: sans, fontWeight: 600, fontSize: 52, lineHeight: 1.18 },
h('div', {}, 'Indonesian stocks and ownership,'),
h('div', { color: c.muted }, 'in your terminal and your agent.'),
),
h('div', { marginTop: 34, fontFamily: mono, fontSize: 24, color: c.soft }, h('span', { color: c.dim, marginRight: 14 }, '$'), `idx ownership ticker ${s.ticker}`),
h(
'div',
{ marginTop: 18, height: 68, gap },
h('div', { width: seg(s.top.pct), background: c.accent }),
...s.others.map((pct, i) => h('div', { width: seg(pct), background: otherShades[i] })),
h('div', {
width: seg(s.freeFloat),
background: c.surface,
backgroundImage: `repeating-linear-gradient(45deg, ${c.line} 0px, ${c.line} 3px, ${c.surface} 3px, ${c.surface} 10px)`,
}),
),
h(
'div',
{ marginTop: 18, position: 'relative', height: 72 },
label(s.top.name, `${s.top.pct.toFixed(2)}%`),
label(`+${s.others.length} holders`, `${othersPct.toFixed(2)}%`, { position: 'absolute', left: seg(s.top.pct) + gap }),
label('FREE FLOAT', `${s.freeFloat.toFixed(2)}%`, { position: 'absolute', right: 0, alignItems: 'flex-end' }),
),
h('div', { flexGrow: 1 }),
footer('idx.rasyidanaf.com', 'CLI · MCP server · MIT'),
),
);
}
/** A docs page card: section, title and description. */
export function docsCard(page: { section: string; title: string; description?: string; path: string }): Promise<Buffer> {
return png(
frame(
wordmark,
h('div', { flexGrow: 1 }),
h('div', { fontFamily: mono, fontSize: 24, letterSpacing: 2, color: c.link }, page.section.toUpperCase()),
h('div', { marginTop: 18, fontFamily: sans, fontWeight: 600, fontSize: page.title.length > 22 ? 64 : 76, lineHeight: 1.1, maxWidth: 1000 }, page.title),
...(page.description
? [h('div', { marginTop: 24, fontFamily: sans, fontSize: 32, lineHeight: 1.35, color: c.muted, maxWidth: 1000 }, page.description)]
: []),
h('div', { flexGrow: 1 }),
h('div', { height: 2, background: c.line, marginBottom: 26 }),
footer(`idx.rasyidanaf.com${page.path}`),
),
);
}

View file

@ -25,7 +25,8 @@ const installs = [
const agents = mcpClients;
// Captured by hand from `idx ownership ticker BBCA` on the 2026-09-30 snapshot;
// the section shows that date. Refresh it (and the terminal panel) after a new month.
// the section shows that date. Refresh it (and the terminal panel, and the
// og.png spotlight in src/og/cards.ts) after a new month.
const holders = [
{ name: 'DWIMURIA INVESTAMA ANDALAN', type: 'Corporate', shares: '67,729,950,000', pct: 54.94 },
{ name: 'ANTHONI SALIM', type: 'Individual', shares: '1,416,306,835', pct: 1.15 },
@ -51,6 +52,11 @@ const copyIcon =
<meta property="og:description" content="A Rust CLI and MCP server for IDX market data and KSEI shareholder ownership." />
<meta property="og:url" content="https://idx.rasyidanaf.com/" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://idx.rasyidanaf.com/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="idx: Indonesian stocks and ownership, in your terminal and your agent. BBCA's KSEI ownership split." />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<header class="bar">

4
web/src/pages/og.png.ts Normal file
View file

@ -0,0 +1,4 @@
import type { APIRoute } from 'astro';
import { siteCard } from '../og/cards';
export const GET: APIRoute = async () => new Response(await siteCard(), { headers: { 'Content-Type': 'image/png' } });

View file

@ -0,0 +1,28 @@
import type { APIRoute, GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
import { docsCard } from '../../og/cards';
// Sidebar group labels, by the first segment of the page's path.
const sections: Record<string, string> = {
start: 'Start',
cli: 'CLI',
mcp: 'MCP server',
agents: 'For agents',
about: 'About',
};
export const getStaticPaths = (async () => {
const pages = await getCollection('docs');
return pages.map((page) => ({
params: { slug: page.id },
props: {
section: sections[page.id.split('/')[0]] ?? 'Docs',
title: page.data.title,
description: page.data.description,
path: `/${page.id}/`,
},
}));
}) satisfies GetStaticPaths;
export const GET: APIRoute = async ({ props }) =>
new Response(await docsCard(props as Parameters<typeof docsCard>[0]), { headers: { 'Content-Type': 'image/png' } });

13
web/src/routeData.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineRouteMiddleware } from '@astrojs/starlight/route-data';
// Point each docs page's og:image at its card from src/pages/og/[...slug].png.ts.
export const onRequest = defineRouteMiddleware((context) => {
const { entry, head } = context.locals.starlightRoute;
const image = new URL(`/og/${entry.id}.png`, context.site);
head.push(
{ tag: 'meta', attrs: { property: 'og:image', content: image.href } },
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
{ tag: 'meta', attrs: { property: 'og:image:alt', content: `${entry.data.title} | idx` } },
);
});