Add OpenGraph/Twitter cards, generated per sin at build time

Link previews are the funnel for a "send the link" site, so give every sin
a share-optimized card.

- og/twitter meta tags on every page (poke as description, canonical URL,
  summary_large_image)
- Per-sin 1200x630 card generated at build: the poke, the before/after
  charts (uniform height so tall vconcat charts fit), severity meter,
  category, and short citations — composed with satori, rasterized with
  @resvg/resvg-js, no browser needed
- Default brand card for the homepage and canon
- Shared chart pipeline extracted to lib/renderChart.ts (used by both the
  on-page component and the card generator)
- Bundle three IBM Plex TTF weights for the rasterizer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPEvmMbac2fCvKXpQcovj8
This commit is contained in:
Claude
2026-08-05 14:03:40 +00:00
parent f010ec6a3c
commit dc0ce07dce
11 changed files with 243 additions and 49 deletions
+3
View File
@@ -14,7 +14,10 @@
"@astrojs/mdx": "^4.2.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.6.0",
"satori": "^0.29.0",
"satori-html": "^0.3.2",
"vega": "^5.30.0",
"vega-lite": "^5.21.0"
}
+3 -48
View File
@@ -8,8 +8,8 @@
//
// The chart sits on a fixed light canvas (--chart-canvas) in both themes, so
// this single baked render keeps AA contrast in light and dark.
import * as vega from 'vega';
import { compile, type TopLevelSpec, type Config } from 'vega-lite';
import type { TopLevelSpec } from 'vega-lite';
import { renderChartSvg } from '../lib/renderChart';
interface Props {
spec: TopLevelSpec;
@@ -19,56 +19,11 @@ interface Props {
const { spec, title, caption } = Astro.props;
// Chart theme — the visual parameters the renderer consumes.
const chartTheme: Config = {
font: 'IBM Plex Sans',
background: 'transparent',
view: { stroke: null },
title: {
color: '#161616',
font: 'IBM Plex Sans',
fontSize: 13,
fontWeight: 600,
anchor: 'start',
dy: -8,
},
axis: {
labelFont: 'IBM Plex Sans',
labelFontSize: 11,
labelColor: '#525252',
titleFont: 'IBM Plex Sans',
titleFontSize: 12,
titleFontWeight: 600,
titleColor: '#393939',
gridColor: '#e0e0e0',
domainColor: '#8d8d8d',
tickColor: '#8d8d8d',
labelPadding: 4,
},
legend: {
labelFont: 'IBM Plex Sans',
labelFontSize: 11,
labelColor: '#525252',
titleFont: 'IBM Plex Sans',
titleFontSize: 12,
titleColor: '#393939',
symbolType: 'square',
},
// Categorical palette (validated colorblind-safe/contrast on the
// --chart-canvas surface).
range: {
category: ['#8a3ffc', '#009d9a', '#fa4d56', '#0f62fe', '#24a148', '#d02670', '#b28600', '#1192e8'],
},
};
let svg = '';
let error: string | null = null;
try {
const vgSpec = compile(spec, { config: chartTheme }).spec;
const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize();
svg = await view.toSVG();
view.finalize();
svg = await renderChartSvg(spec);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error(`[VegaChart] failed to render "${title}": ${error}`);
+21
View File
@@ -10,10 +10,15 @@ import { href } from '../lib/url';
interface Props {
title: string;
description?: string;
/** Absolute OG image URL; falls back to the brand card. */
image?: string;
}
const { title, description = 'A catalogue of data-visualization sins — the deceptive chart, why it misleads, and the honest fix.' } =
Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const image = Astro.props.image ?? new URL(href('og/default.png'), Astro.site).toString();
---
<!doctype html>
@@ -22,6 +27,22 @@ const { title, description = 'A catalogue of data-visualization sins — the dec
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Chart Sins" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={image} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<title>{title}</title>
</head>
<body>
+126
View File
@@ -0,0 +1,126 @@
import fs from 'node:fs';
import path from 'node:path';
import satori from 'satori';
import { html } from 'satori-html';
import { Resvg } from '@resvg/resvg-js';
import type { TopLevelSpec } from 'vega-lite';
import { renderChartSvg } from './renderChart';
import { getReference } from './references';
// OpenGraph card generator. Runs only at build time. Composes a 1200×630 card
// with satori (text → vector paths, so the raster step needs no fonts) and
// rasterizes to PNG with resvg. Charts are rendered by the shared Vega pipeline,
// rasterized to PNG (resvg, using the bundled IBM Plex TTFs for axis text), and
// embedded as images.
const OG_W = 1200;
const OG_H = 630;
// Resolve from the project root so it works after the module is bundled into
// dist/chunks/ at build time.
const fontDir = path.join(process.cwd(), 'src/og/fonts');
const fontPath = (f: string) => path.join(fontDir, f);
const fontFiles = [
fontPath('IBMPlexSans-Light.ttf'),
fontPath('IBMPlexSans-Regular.ttf'),
fontPath('IBMPlexSans-SemiBold.ttf'),
];
const fonts = [
{ name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[0]), weight: 300 as const, style: 'normal' as const },
{ name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[1]), weight: 400 as const, style: 'normal' as const },
{ name: 'IBM Plex Sans', data: fs.readFileSync(fontFiles[2]), weight: 600 as const, style: 'normal' as const },
];
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Surname of the first author, for a compact "cited: Tufte, Cairo" line. */
function shortAuthor(key: string): string {
const first = getReference(key).authors.split(/ & |,|;/)[0].trim();
const parts = first.split(' ');
return parts[parts.length - 1];
}
/** Render a chart spec to a PNG data URI at a fixed display height (2× for crispness).
* Fixing height (not width) keeps tall vconcat charts from overflowing the card. */
async function chartImage(spec: TopLevelSpec, displayHeight: number) {
const svg = await renderChartSvg(spec);
const resvg = new Resvg(svg, {
fitTo: { mode: 'height', value: displayHeight * 2 },
font: { fontFiles, loadSystemFonts: false, defaultFontFamily: 'IBM Plex Sans' },
background: '#f4f4f4',
});
const rendered = resvg.render();
const png = rendered.asPng();
return {
uri: `data:image/png;base64,${png.toString('base64')}`,
width: Math.round(rendered.width / 2),
height: displayHeight,
};
}
async function toPng(markupStr: string): Promise<Buffer> {
const svg = await satori(html(markupStr.trim()), { width: OG_W, height: OG_H, fonts });
return Buffer.from(new Resvg(svg, { font: { fontFiles, loadSystemFonts: false } }).render().asPng());
}
function meter(severity: number): string {
// Each square needs its own `display` — satori rejects a flex row of empty divs otherwise.
return Array.from({ length: 5 }, (_, i) =>
`<div style="display:flex;width:20px;height:20px;background:${i < severity ? '#fa4d56' : '#525252'}"></div>`,
).join('');
}
const wordmark = `<div style="display:flex;font-size:22px;font-weight:600;letter-spacing:4px;color:#f4f4f4">CHART SINS</div>`;
export interface SinCard {
poke: string;
category: string;
severity: number;
badSpec: TopLevelSpec;
fixedSpec: TopLevelSpec;
citationKeys: string[];
}
export async function renderSinCard(d: SinCard): Promise<Buffer> {
const bad = await chartImage(d.badSpec, 250);
const fixed = await chartImage(d.fixedSpec, 250);
const cited = d.citationKeys.slice(0, 3).map(shortAuthor).join(', ');
const panel = (label: string, color: string, img: { uri: string; width: number; height: number }) => `
<div style="display:flex;flex-direction:column;background:#f4f4f4;padding:12px;border-top:5px solid ${color}">
<div style="display:flex;font-size:13px;font-weight:600;letter-spacing:1px;color:${color};margin-bottom:6px">${label}</div>
<img src="${img.uri}" style="width:${img.width}px;height:${img.height}px" />
</div>`;
return toPng(`
<div style="display:flex;flex-direction:column;width:${OG_W}px;height:${OG_H}px;background:#161616;padding:44px 56px">
<div style="display:flex;align-items:center;justify-content:space-between">
${wordmark}
<div style="display:flex;gap:6px">${meter(d.severity)}</div>
</div>
<div style="display:flex;font-size:40px;font-weight:300;line-height:1.15;color:#f4f4f4;margin-top:22px;max-width:1090px">${esc(d.poke)}</div>
<div style="display:flex;gap:22px;margin-top:26px;justify-content:center">
${panel('THE SIN', '#fa4d56', bad)}
${panel('THE FIX', '#42be65', fixed)}
</div>
<div style="display:flex;justify-content:space-between;margin-top:22px;font-size:17px;color:#a8a8a8">
<div style="display:flex">${esc(d.category)}</div>
<div style="display:flex">${cited ? 'cited: ' + esc(cited) : ''}</div>
</div>
</div>
`);
}
export async function renderDefaultCard(): Promise<Buffer> {
return toPng(`
<div style="display:flex;flex-direction:column;justify-content:space-between;width:${OG_W}px;height:${OG_H}px;background:#161616;padding:64px 72px">
${wordmark}
<div style="display:flex;flex-direction:column">
<div style="display:flex;font-size:66px;font-weight:300;line-height:1.08;color:#f4f4f4;max-width:1000px">Charts that lie, and the honest fix.</div>
<div style="display:flex;font-size:26px;color:#a8a8a8;margin-top:24px;max-width:900px">The deceptive chart, why it fools the eye, and the honest version — with citations. Send the link instead of re-explaining.</div>
</div>
<div style="display:flex;font-size:18px;color:#6f6f6f">a catalogue of data-visualization sins</div>
</div>
`);
}
+55
View File
@@ -0,0 +1,55 @@
import * as vega from 'vega';
import { compile, type TopLevelSpec, type Config } from 'vega-lite';
// Chart theme — the visual parameters the renderer consumes. Shared by the
// on-page VegaChart component and the build-time OpenGraph card generator, so
// both render charts identically.
export const chartTheme: Config = {
font: 'IBM Plex Sans',
background: 'transparent',
view: { stroke: null },
title: {
color: '#161616',
font: 'IBM Plex Sans',
fontSize: 13,
fontWeight: 600,
anchor: 'start',
dy: -8,
},
axis: {
labelFont: 'IBM Plex Sans',
labelFontSize: 11,
labelColor: '#525252',
titleFont: 'IBM Plex Sans',
titleFontSize: 12,
titleFontWeight: 600,
titleColor: '#393939',
gridColor: '#e0e0e0',
domainColor: '#8d8d8d',
tickColor: '#8d8d8d',
labelPadding: 4,
},
legend: {
labelFont: 'IBM Plex Sans',
labelFontSize: 11,
labelColor: '#525252',
titleFont: 'IBM Plex Sans',
titleFontSize: 12,
titleColor: '#393939',
symbolType: 'square',
},
// Categorical palette (validated colorblind-safe/contrast on the
// --chart-canvas surface).
range: {
category: ['#8a3ffc', '#009d9a', '#fa4d56', '#0f62fe', '#24a148', '#d02670', '#b28600', '#1192e8'],
},
};
/** Compile a Vega-Lite spec and render it headlessly to a static SVG string. */
export async function renderChartSvg(spec: TopLevelSpec): Promise<string> {
const vgSpec = compile(spec, { config: chartTheme }).spec;
const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).initialize();
const svg = await view.toSVG();
view.finalize();
return svg;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
import type { APIRoute, GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
import { getChartSpec } from '../../lib/charts';
import { renderSinCard } from '../../lib/og';
export const getStaticPaths: GetStaticPaths = async () => {
const sins = await getCollection('sins', ({ data }) => !data.draft);
return sins.map((sin) => ({ params: { slug: sin.id }, props: { sin } }));
};
export const GET: APIRoute = async ({ props }) => {
const { sin } = props as { sin: Awaited<ReturnType<typeof getCollection>>[number] };
const png = await renderSinCard({
poke: sin.data.poke,
category: sin.data.category,
severity: sin.data.severity,
badSpec: getChartSpec(sin.data.badChart),
fixedSpec: getChartSpec(sin.data.fixedChart),
citationKeys: sin.data.citations.map((c) => c.key),
});
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable' },
});
};
+9
View File
@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { renderDefaultCard } from '../../lib/og';
export const GET: APIRoute = async () => {
const png = await renderDefaultCard();
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable' },
});
};
+2 -1
View File
@@ -23,9 +23,10 @@ const fixedSpec = getChartSpec(sin.data.fixedChart);
// Canonical, shareable URL for this sin — the thing you paste into a thread.
const pageUrl = new URL(href(`sins/${sin.id}/`), Astro.site).toString();
const ogImage = new URL(href(`og/${sin.id}.png`), Astro.site).toString();
---
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.poke}>
<BaseLayout title={`${sin.data.title} — Chart Sins`} description={sin.data.poke} image={ogImage}>
<article class="wrap sin">
<nav class="breadcrumb"><a href={href()}>All sins</a> / {sin.data.title}</nav>