// the link preview card every page gets (og:image): the window on its desk in the default
// palette's light half, with the page's icon, title and one line about it. drawn as svg here
// and turned into a png by resvg at build time, set in the site's own fonts.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Resvg, type ResvgRenderOptions } from "@resvg/resvg-js";
import { decompress } from "wawoff2";
import { blurbs, firstParagraph } from "./blurbs";
import type { Doc } from "./docs";
import { standalone } from "./icons";
import { colours } from "./palettes";
import { DEFAULT_PALETTE } from "./themes";
export const OG_WIDTH = 1200;
export const OG_HEIGHT = 630;
export interface Card {
icon: string;
title: string;
/** the section it's in, above the title */
kicker?: string;
/** plain text, wrapped to three lines at most */
blurb?: string;
}
// resvg only reads ttf/otf, and fontsource ships woff2
let loaded: Promise<ResvgRenderOptions> | undefined;
const fonts = () => (loaded ??= loadFonts());
async function loadFonts(): Promise<ResvgRenderOptions> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hydrant-og-"));
const faces = [
["hanken-grotesk", "latin-400-normal"],
["hanken-grotesk", "latin-700-normal"],
["geist-mono", "latin-400-normal"],
];
// one at a time: wawoff2 decodes in one shared wasm instance, and overlapping calls hand
// each other's output back
const fontFiles: string[] = [];
for (const [pkg, face] of faces) {
const woff2 = fs.readFileSync(path.join(process.cwd(), `node_modules/@fontsource/${pkg}/files/${pkg}-${face}.woff2`));
const file = path.join(dir, `${pkg}-${face}.ttf`);
fs.writeFileSync(file, await decompress(woff2));
fontFiles.push(file);
}
return { font: { loadSystemFonts: false, fontFiles, defaultFontFamily: "Hanken Grotesk" } };
}
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
/** greedy word wrap, measuring each candidate line with resvg itself */
const measure = (s: string, size: number, opts: ResvgRenderOptions, weight = 400) =>
new Resvg(
`<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="200"><text x="0" y="100" font-size="${size}" font-weight="${weight}">${esc(s)}</text></svg>`,
opts,
).getBBox()?.width ?? 0;
function wrap(text: string, size: number, width: number, maxLines: number, opts: ResvgRenderOptions): string[] {
const fits = (s: string) => measure(s, size, opts) <= width;
const lines: string[] = [];
let line = "";
for (const word of text.split(/\s+/)) {
const next = line ? `${line} ${word}` : word;
if (!line || fits(next)) line = next;
else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
if (lines.length <= maxLines) return lines;
const cut = lines.slice(0, maxLines);
while (!fits(`${cut[maxLines - 1]}…`)) cut[maxLines - 1] = cut[maxLines - 1].replace(/\s*\S+$/, "");
cut[maxLines - 1] += "…";
return cut;
}
export async function card({ icon, title, kicker, blurb }: Card): Promise<Buffer> {
const opts = await fonts();
const c = colours(DEFAULT_PALETTE, "light");
const textX = 372, textW = 690;
const blurbLines = blurb ? wrap(blurb, 28, textW, 3, opts) : [];
// a long title shrinks to fit on one line rather than wrap
const titleSize = Math.min(60, Math.floor((60 * textW) / measure(title, 60, opts, 700)));
// the text block is centred on the paper, like the icon beside it
const height = (kicker ? 40 : 0) + 64 + (blurbLines.length ? 18 + blurbLines.length * 40 : 0);
let y = 352 - height / 2;
const parts: string[] = [];
if (kicker) {
parts.push(`<text x="${textX}" y="${y + 26}" font-size="26" fill="${c["--dim"]}">${esc(kicker)}</text>`);
y += 40;
}
parts.push(`<text x="${textX}" y="${y + 54}" font-size="${titleSize}" font-weight="700" fill="${c["--heading"]}">${esc(title)}</text>`);
y += 64 + 18;
for (const line of blurbLines) {
parts.push(`<text x="${textX}" y="${y + 30}" font-size="28" fill="${c["--text-2"]}">${esc(line)}</text>`);
y += 40;
}
const place = (spec: string, x: number, y: number, size: number) =>
standalone(spec).replace("<svg ", `<svg x="${x}" y="${y}" width="${size}" height="${size}" `);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}">
<defs>
<radialGradient id="desk" cx="0.3" cy="0" r="1.1"><stop offset="0" stop-color="${c["--desk-glow"]}"/><stop offset="0.55" stop-color="${c["--desk"]}"/><stop offset="1" stop-color="${c["--desk-deep"]}"/></radialGradient>
<linearGradient id="tab" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c["--tab-hi"]}"/><stop offset="0.45" stop-color="${c["--tab"]}"/><stop offset="1" stop-color="${c["--tab-mid"]}"/></linearGradient>
<filter id="drop" x="-10%" y="-10%" width="130%" height="140%"><feDropShadow dx="8" dy="14" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/></filter>
<filter id="lift" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="5" dy="5" stdDeviation="0" flood-color="#000" flood-opacity="0.18"/></filter>
</defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#desk)"/>
<g filter="url(#drop)">
<path d="M70 100 V64 a4 4 0 0 1 4 -4 H330 a4 4 0 0 1 4 4 V100 Z" fill="url(#tab)" stroke="${c["--edge"]}" stroke-width="1.5"/>
<rect x="70" y="99" width="1060" height="481" rx="3" fill="${c["--chrome"]}" stroke="${c["--edge"]}" stroke-width="1.5"/>
</g>
<path d="M72 98 V65 a3 3 0 0 1 3 -3 H329" fill="none" stroke="${c["--tab-hi"]}" stroke-width="1.5"/>
${place("hydrant", 84, 64, 32)}
<text x="124" y="89" font-size="22" font-weight="700" fill="${c["--tab-text"]}">hydrant docs</text>
<path d="M71.5 578 V100.5 H1128.5" fill="none" stroke="${c["--hi"]}" stroke-width="1.5"/>
<path d="M1128.5 100.5 V578.5 H71.5" fill="none" stroke="${c["--lo"]}" stroke-width="1.5"/>
<rect x="96" y="124" width="1008" height="432" rx="2" fill="${c["--paper"]}" stroke="${c["--edge"]}" stroke-width="1.5"/>
<path d="M97.5 554 V125.5 H1102.5" fill="none" stroke="${c["--lo-deep"]}" stroke-width="1.5" opacity="0.6"/>
<g filter="url(#lift)">${place(icon, 140, 260, 184)}</g>
${parts.join("\n ")}
<text x="1080" y="532" font-size="20" font-family="Geist Mono" text-anchor="end" fill="${c["--dim"]}">hydrant.klbr.net</text>
</svg>`;
return new Resvg(svg, { ...opts, fitTo: { mode: "original" } }).render().asPng();
}
const plain = (html: string) =>
html
.replace(/<[^>]+>/g, "")
.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" })[e as string]!)
.replace(/\s+/g, " ")
.trim();
/** a doc's card, and the plain line about it that is also its meta description */
export function cardFor(doc: Doc, docs: Map<string, Doc>): Card & { description?: string } {
const html = blurbs(docs).get(doc.id) ?? firstParagraph(doc);
const blurb = html && plain(html);
const { section, label, icon, isIndex } = doc.place;
return {
icon,
title: doc.id === "README" ? "hydrant" : isIndex ? `${section.title} overview` : label,
kicker: doc.id === "README" || isIndex ? undefined : section.title,
blurb,
description: blurb,
};
}
/** where a page's card is served, next to the page */
export const ogPath = (doc?: Doc) => (doc ? `/og${doc.url.slice(0, -1)}.png` : "/og.png");