mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
21bf6b204a
commit
0859c00020
9 changed files with 248 additions and 1 deletions
150
web/src/og/cards.ts
Normal file
150
web/src/og/cards.ts
Normal 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}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
4
web/src/pages/og.png.ts
Normal 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' } });
|
||||
28
web/src/pages/og/[...slug].png.ts
Normal file
28
web/src/pages/og/[...slug].png.ts
Normal 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
13
web/src/routeData.ts
Normal 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` } },
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue