diff --git a/package.json b/package.json index 755fa8b..03b24f5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "content:lint": "node scripts/content-lint.mjs", "seo:smoke": "node scripts/seo-smoke.mjs", "svg:lint": "node scripts/svg-text-fit.mjs", - "build": "node scripts/gen-zh-hant.mjs && node scripts/content-lint.mjs --published && node scripts/svg-text-fit.mjs && astro build && node scripts/content-lint.mjs --dist", + "og:images": "node scripts/og-images.mjs", + "build": "node scripts/gen-zh-hant.mjs && node scripts/content-lint.mjs --published && node scripts/svg-text-fit.mjs && astro build && node scripts/og-images.mjs && node scripts/content-lint.mjs --dist", "preview": "astro preview", "check": "astro check" }, @@ -28,8 +29,15 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@fontsource-variable/arimo": "^5.3.0", + "@fontsource-variable/noto-emoji": "^5.3.2", + "@fontsource-variable/noto-sans-sc": "^5.3.0", + "@fontsource/cousine": "^5.3.0", + "@fontsource/noto-sans-math": "^5.3.0", + "@fontsource/noto-sans-symbols-2": "^5.3.0", "marked": "^18.0.4", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "wawoff2": "^2.0.1" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07f99dc..e2f1c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,12 +30,33 @@ importers: '@astrojs/check': specifier: ^0.9.9 version: 0.9.9(prettier@3.8.3)(typescript@5.9.3) + '@fontsource-variable/arimo': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/noto-emoji': + specifier: ^5.3.2 + version: 5.3.2 + '@fontsource-variable/noto-sans-sc': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/cousine': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/noto-sans-math': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/noto-sans-symbols-2': + specifier: ^5.3.0 + version: 5.3.0 marked: specifier: ^18.0.4 version: 18.0.4 typescript: specifier: ^5.9.3 version: 5.9.3 + wawoff2: + specifier: ^2.0.1 + version: 2.0.1 packages: @@ -443,6 +464,24 @@ packages: cpu: [x64] os: [win32] + '@fontsource-variable/arimo@5.3.0': + resolution: {integrity: sha512-t34ntseQmQX5nLt4nbAmjm4a2sZK5jzcYNBkehOqTg9xlbctc2ZrzlGZlptibfl9Trvsg9buhuoiqBBdm8VK8Q==} + + '@fontsource-variable/noto-emoji@5.3.2': + resolution: {integrity: sha512-LgtrM0SErtdfSvrI3CqtHeCtToGCVN66hX+HVNv9QHF5KX0Ko1swzUsSMv1y6Jwjxp3Wzkp8e5PPcl7GLZadOw==} + + '@fontsource-variable/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==} + + '@fontsource/cousine@5.3.0': + resolution: {integrity: sha512-EhVes1KpHrmPwGVCoVxYpdiIi/L2LRGxbAuv07m3TdY76f9Vmwyhd/TNqi2RC0NOvMCNt2FtYPJcAfFfkK9DoQ==} + + '@fontsource/noto-sans-math@5.3.0': + resolution: {integrity: sha512-CzodkZ4R2VC73rqbNbVpiI94fXZJLFEMNBTAM3DFadjAKrjLQ2Fr+s2yM2c5uS+frBZ2IM+LyMo6COhAzV7hCA==} + + '@fontsource/noto-sans-symbols-2@5.3.0': + resolution: {integrity: sha512-hFnEnIXOIgLLJ61SO9icRXKoS+LSWoq7ZqKJQ/NaQR7ptdC6+7yW4ci6EuXgsS1S9JmWpciX6w0AFZkU1DKn1A==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2093,6 +2132,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + wawoff2@2.0.1: + resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==} + hasBin: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -2487,6 +2530,18 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fontsource-variable/arimo@5.3.0': {} + + '@fontsource-variable/noto-emoji@5.3.2': {} + + '@fontsource-variable/noto-sans-sc@5.3.0': {} + + '@fontsource/cousine@5.3.0': {} + + '@fontsource/noto-sans-math@5.3.0': {} + + '@fontsource/noto-sans-symbols-2@5.3.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -4579,6 +4634,10 @@ snapshots: vscode-uri@3.1.0: {} + wawoff2@2.0.1: + dependencies: + argparse: 2.0.1 + web-namespaces@2.0.1: {} which-pm-runs@1.1.0: {} diff --git a/scripts/lib/og-fonts.mjs b/scripts/lib/og-fonts.mjs new file mode 100644 index 0000000..b037b65 --- /dev/null +++ b/scripts/lib/og-fonts.mjs @@ -0,0 +1,269 @@ +// The fonts `scripts/og-images.mjs` rasterizes SVG cover text with, and the +// check that every character a cover draws is in one of them. +// +// ─── Why the fonts are bundled, not taken from the machine ───────────────── +// +// sharp renders SVG through librsvg, Pango and fontconfig, and fontconfig +// answers with whatever fonts the machine has. The covers ask for +// `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`, `Inter, Arial, +// sans-serif` and `ui-monospace, Menlo, monospace`: macOS and Windows faces +// that no Linux build machine has. CI builds on GitHub's ubuntu-latest image, +// which ships no CJK font, so the Chinese covers (58 zh-Hans and zh-Hant posts) +// would rasterize as rows of empty boxes ("tofu"), and the share image is +// exactly the place nobody looks at after merge. So the rasterizer sees this +// set and nothing from the system: the same glyphs on a laptop and on CI, and +// a gate that knows what they are. +// +// ─── Why WOFF2 is decoded first ───────────────────────────────────────────── +// +// The faces come from Fontsource packages (SIL OFL 1.1), which ship WOFF2 and +// WOFF only. The FreeType inside sharp's prebuilt libvips reads neither: +// rendering a Chinese cover against the WOFF2 files, and a symbol line +// against the WOFF files, drew every glyph as a box. `wawoff2` (Google's +// woff2 decoder, compiled to WebAssembly) turns each file back into the +// TrueType font it was made from, in a temporary directory that a generated +// fonts.conf points fontconfig at. +// +// ─── The faces ────────────────────────────────────────────────────────────── +// +// Arimo sans-serif. Metric-compatible with Arial, which is +// the face `scripts/svg-text-fit.mjs` measures every +// cover with, so a line that fits its box under +// `pnpm svg:lint` has the same width in the share +// image. (Inter or a San Francisco look-alike would be +// wider, and could overflow a box the gate passed.) +// Cousine monospace, metric-compatible with Courier New / +// Liberation Mono, the gate's monospace table. +// Noto Sans SC Chinese. The zh-Hans and zh-Hant posts share one +// Simplified-Chinese cover per post (AGENTS.md); the +// other locales use the English cover. +// Noto Sans Symbols 2 ✓ ✗ ⛔ and the other dingbats in the diagrams. +// Noto Sans Math the arrows and operators the others lack (↶ ⊘). +// Noto Emoji the emoji (💥 🤔), in monochrome: the COLRv1 +// colour emoji font decodes, but cairo draws nothing +// for it. +// +// Every family a cover names resolves to the sans-serif or monospace chain +// below, and both chains end in every face, so "some bundled face has the +// glyph" is exactly "fontconfig can draw it". That is the predicate +// `uncoveredChars` checks, against each face's own cmap. + +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); + +/** + * The bundled faces, in fallback order. `files` picks the slices to load: the + * Latin faces only need their Latin slices, the CJK face needs all of them. + */ +export const OG_FACES = [ + { family: 'Arimo', pkg: '@fontsource-variable/arimo', files: /^arimo-latin(-ext)?-wght-normal\.woff2$/ }, + { family: 'Cousine', pkg: '@fontsource/cousine', files: /^cousine-latin(-ext)?-(400|700)-normal\.woff2$/ }, + { family: 'Noto Sans SC', pkg: '@fontsource-variable/noto-sans-sc', files: /^noto-sans-sc-.*-wght-normal\.woff2$/ }, + { family: 'Noto Sans Symbols 2', pkg: '@fontsource/noto-sans-symbols-2', files: /^noto-sans-symbols-2-.*-400-normal\.woff2$/ }, + { family: 'Noto Sans Math', pkg: '@fontsource/noto-sans-math', files: /^noto-sans-math-.*-400-normal\.woff2$/ }, + { family: 'Noto Emoji', pkg: '@fontsource-variable/noto-emoji', files: /^noto-emoji-.*-wght-normal\.woff2$/ }, +]; + +// The families the covers name, mapped to the chain they belong to. Anything +// else falls through to sans-serif (the `append_last` rule in fonts.conf). +const SANS_NAMES = ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'system-ui', 'ui-sans-serif', 'Inter', 'Arial', 'Helvetica', 'Helvetica Neue']; +const MONO_NAMES = ['ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'Monaco', 'Consolas', 'Courier New', 'monospace']; +// The symbol faces come before the CJK face: Noto Sans SC also has ✓ and the +// arrows, but as full-width CJK glyphs, which sit badly in a Latin line; a +// Chinese line reads fine with the Latin-proportioned ones. +const SANS_CHAIN = ['Arimo', 'Noto Sans Symbols 2', 'Noto Sans Math', 'Noto Sans SC', 'Noto Emoji']; +const MONO_CHAIN = ['Cousine', 'Noto Sans Symbols 2', 'Noto Sans Math', 'Noto Sans SC', 'Noto Emoji', 'Arimo']; + +const xml = (s) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +function fontsConf(fontDir, cacheDir) { + const prefer = (chain) => chain.map((f) => `${xml(f)}`).join(''); + const aliases = (names, generic) => + names + .filter((name) => name !== generic) + .map((name) => ` ${xml(name)}${generic}`) + .join('\n'); + return ` + + + + ${xml(fontDir)} + ${xml(cacheDir)} + sans-serif +${aliases(SANS_NAMES, 'sans-serif')} +${aliases(MONO_NAMES, 'monospace')} + serifsans-serif + sans-serif${prefer(SANS_CHAIN)} + monospace${prefer(MONO_CHAIN)} + +`; +} + +/** + * Decode the bundled faces into `dir` and write the fonts.conf that points + * fontconfig at them. Must run, and `FONTCONFIG_FILE` must be set to the + * returned path, before sharp first renders text in the process: fontconfig + * reads its configuration once. + * + * @param {string} dir An empty scratch directory. + * @returns {Promise<{ conf: string, faces: { family: string, file: string, cmap: Cmap }[] }>} + */ +export async function prepareOgFonts(dir) { + const decompress = require('wawoff2/decompress.js'); + const fontDir = path.join(dir, 'fonts'); + const cacheDir = path.join(dir, 'cache'); + mkdirSync(fontDir, { recursive: true }); + mkdirSync(cacheDir, { recursive: true }); + const faces = []; + for (const face of OG_FACES) { + const filesDir = path.join(path.dirname(require.resolve(`${face.pkg}/package.json`)), 'files'); + const picked = readdirSync(filesDir).filter((name) => face.files.test(name)).sort(); + if (picked.length === 0) { + throw new Error(`og-fonts: ${face.pkg} has no file matching ${face.files} (did the package layout change?)`); + } + for (const name of picked) { + const ttf = Buffer.from(await decompress(readFileSync(path.join(filesDir, name)))); + const file = path.join(fontDir, name.replace(/\.woff2$/, '.ttf')); + writeFileSync(file, ttf); + faces.push({ family: face.family, file, cmap: readCmap(ttf, name) }); + } + } + const conf = path.join(dir, 'fonts.conf'); + writeFileSync(conf, fontsConf(fontDir, cacheDir)); + return { conf, faces }; +} + +// ─── cmap ─────────────────────────────────────────────────────────────────── +// +// Which code points a font maps to a glyph, read from its `cmap` table: the +// Unicode subtables in format 4 (BMP segments) and 12 (full-range groups). +// A code point that maps to glyph 0 (.notdef) is not covered; that glyph is +// the box. + +/** @typedef {{ has(cp: number): boolean }} Cmap */ + +/** + * @param {Buffer} font A TrueType/OpenType file. + * @param {string} name For error messages. + * @returns {Cmap} + */ +export function readCmap(font, name) { + const numTables = font.readUInt16BE(4); + let cmap = -1; + for (let i = 0; i < numTables; i++) { + const rec = 12 + i * 16; + if (font.toString('latin1', rec, rec + 4) === 'cmap') cmap = font.readUInt32BE(rec + 8); + } + if (cmap < 0) throw new Error(`og-fonts: ${name} has no cmap table`); + /** @type {((cp: number) => boolean)[]} */ + const lookups = []; + const seen = new Set(); + const records = font.readUInt16BE(cmap + 2); + for (let i = 0; i < records; i++) { + const rec = cmap + 4 + i * 8; + const platform = font.readUInt16BE(rec); + const encoding = font.readUInt16BE(rec + 2); + const unicode = platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10)); + const offset = cmap + font.readUInt32BE(rec + 4); + if (!unicode || seen.has(offset)) continue; + seen.add(offset); + const format = font.readUInt16BE(offset); + if (format === 4) lookups.push(format4(font, offset)); + else if (format === 12) lookups.push(format12(font, offset)); + } + if (lookups.length === 0) throw new Error(`og-fonts: ${name} has no Unicode cmap subtable in format 4 or 12`); + return { has: (cp) => lookups.some((lookup) => lookup(cp)) }; +} + +function format4(font, at) { + const segCount = font.readUInt16BE(at + 6) / 2; + const ends = at + 14; + const starts = ends + segCount * 2 + 2; + const deltas = starts + segCount * 2; + const rangeOffsets = deltas + segCount * 2; + return (cp) => { + if (cp > 0xffff) return false; + for (let s = 0; s < segCount; s++) { + if (cp > font.readUInt16BE(ends + s * 2)) continue; + const start = font.readUInt16BE(starts + s * 2); + if (cp < start) return false; + const delta = font.readUInt16BE(deltas + s * 2); + const roAt = rangeOffsets + s * 2; + const ro = font.readUInt16BE(roAt); + if (ro === 0) return ((cp + delta) & 0xffff) !== 0; + const glyph = font.readUInt16BE(roAt + ro + (cp - start) * 2); + return glyph !== 0 && ((glyph + delta) & 0xffff) !== 0; + } + return false; + }; +} + +function format12(font, at) { + const groups = font.readUInt32BE(at + 12); + return (cp) => { + for (let g = 0; g < groups; g++) { + const rec = at + 16 + g * 12; + const start = font.readUInt32BE(rec); + const end = font.readUInt32BE(rec + 4); + if (cp >= start && cp <= end) return font.readUInt32BE(rec + 8) + (cp - start) !== 0; + } + return false; + }; +} + +// ─── What a cover draws ───────────────────────────────────────────────────── + +const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; +const decodeXml = (s) => + s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => + e[0] === '#' + ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) + : ENTITIES[e] ?? m + ); + +// Not drawn, so not looked up: ASCII whitespace, and the default-ignorable +// code points a shaper consumes (variation selectors, zero-width joiners, +// the BOM). +const NOT_DRAWN = /[\t\n\r ­​-‏⁠-⁤︀-️]/u; + +/** + * The characters an SVG's `` elements draw (tspans included), with the + * comments, styles and the unrendered `title`/`desc`/`metadata` dropped. + * + * @param {string} svg + * @returns {Map} code point -> the first text it appears in + */ +export function svgTextChars(svg) { + const source = svg + .replace(//g, '') + .replace(/<(style|script|title|desc|metadata)\b[\s\S]*?<\/\1\s*>/g, ''); + const chars = new Map(); + for (const [, inner] of source.matchAll(/]*>([\s\S]*?)<\/text\s*>/g)) { + const text = decodeXml(inner.replace(//g, '$1').replace(/<[^>]*>/g, '')); + for (const ch of text) { + if (NOT_DRAWN.test(ch)) continue; + const cp = ch.codePointAt(0); + if (!chars.has(cp)) chars.set(cp, text.trim().replace(/\s+/g, ' ')); + } + } + return chars; +} + +/** + * The characters of an SVG that no bundled face can draw. + * + * @param {string} svg + * @param {{ cmap: Cmap }[]} faces + * @returns {{ char: string, codePoint: string, text: string }[]} + */ +export function uncoveredChars(svg, faces) { + const missing = []; + for (const [cp, text] of svgTextChars(svg)) { + if (faces.some((face) => face.cmap.has(cp))) continue; + missing.push({ char: String.fromCodePoint(cp), codePoint: `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`, text }); + } + return missing; +} diff --git a/scripts/lib/og-image.mjs b/scripts/lib/og-image.mjs new file mode 100644 index 0000000..ac9f170 --- /dev/null +++ b/scripts/lib/og-image.mjs @@ -0,0 +1,76 @@ +// The share image of a blog post: a 1200x630 JPEG cut from its cover at build +// time, named by `og:image`, `twitter:image` and `BlogPosting.image`. +// +// Why a separate image at all: 209 posts (41 of them English) have an SVG +// cover, and X, LinkedIn, Facebook and Slack draw no card for an SVG +// `og:image`; the 80 PNG covers are 1.5-1.9 MB at 1672x941, and the share card +// crops them to 1.91:1 anyway. So every post with a cover gets one raster at +// the size the networks ask for, whatever format its cover is. +// +// Two readers, one rule. src/layouts/BlogPost.astro names the image in the +// page head; scripts/og-images.mjs renders it into dist/ after `astro build` +// and then checks that every page's head names a file it rendered, at the size +// the head declares. Both derive the URL from `ogImagePath` below, so the page +// and the file cannot be computed two ways. +// +// The URL carries a hash of the cover's bytes (and of OG_RENDER_REVISION), so +// an edited cover gets a new URL: the networks cache a share image by URL, for +// days, and a re-scrape of the post then fetches the new one. + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { cwd } from 'node:process'; +import yaml from 'js-yaml'; +import { splitFrontmatter } from './frontmatter.mjs'; + +/** The share image's size: the 1.91:1 card X, LinkedIn and Facebook all lay out. */ +export const OG_IMAGE = { width: 1200, height: 630 }; + +/** + * Part of every share image's URL hash. Bump it when the rendering itself + * changes (crop, fonts, encoder settings) so the networks fetch the new files. + */ +export const OG_RENDER_REVISION = '1'; + +/** + * The cover file a post's frontmatter names, as an absolute path, or + * `undefined` for a post with no cover. Throws when the named file is missing, + * which `astro build` would already have refused. + * + * @param {string} mdxFile The post's `.mdx`, absolute or relative to `root`. + * @param {string} [root] Project root (the directory holding `content/`); the + * working directory by default, which is where `astro build` runs. + */ +export function postCoverFile(mdxFile, root = cwd()) { + const file = path.resolve(root, mdxFile); + const split = splitFrontmatter(readFileSync(file, 'utf8')); + if (!split.ok) throw new Error(`${mdxFile}: cannot read its frontmatter (${split.reason})`); + const data = yaml.load(split.raw); + const cover = data && typeof data === 'object' ? data.cover : undefined; + if (cover === undefined || cover === null) return undefined; + if (typeof cover !== 'string') throw new Error(`${mdxFile}: \`cover\` is not a path`); + const coverFile = path.resolve(path.dirname(file), cover); + if (!existsSync(coverFile)) throw new Error(`${mdxFile}: cover ${cover} does not exist`); + return coverFile; +} + +/** + * The URL path of the share image rendered from a cover under content/blog: + * `ai-agent-workbench/cover.png` -> `/og/blog/ai-agent-workbench/cover..jpg`. + * + * @param {string} coverFile Absolute path of the cover. + * @param {string} [root] Project root; the working directory by default. + */ +export function ogImagePath(coverFile, root = cwd()) { + const relative = path.relative(path.join(root, 'content', 'blog'), coverFile).split(path.sep).join('/'); + if (relative.startsWith('../') || path.isAbsolute(relative)) { + throw new Error(`${coverFile}: a blog cover must live under content/blog/`); + } + const hash = createHash('sha256') + .update(readFileSync(coverFile)) + .update(`og-render-${OG_RENDER_REVISION}`) + .digest('hex') + .slice(0, 10); + return `/og/blog/${relative.replace(/\.[^./]+$/, '')}.${hash}.jpg`; +} diff --git a/scripts/og-images.mjs b/scripts/og-images.mjs new file mode 100644 index 0000000..ad0c0f8 --- /dev/null +++ b/scripts/og-images.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node +// Share images: render each blog post's 1200x630 JPEG `og:image` from its +// cover, after `astro build`, and check that every built page names one that +// exists at the size it declares. +// +// node scripts/og-images.mjs (pnpm og:images; runs in `pnpm build`) +// +// What the page names and where the file comes from are one rule, in +// scripts/lib/og-image.mjs: src/layouts/BlogPost.astro writes the URL into +// the head, this script renders the file at that URL. Driven from the built +// HTML, so it renders exactly the images some page names (no archived post's, +// no orphan) and fails on any it cannot account for. +// +// Steps, each a build failure when it finds something: +// 1. Read `og:image`, `twitter:image` and the JSON-LD `image` of every built +// page. Each URL under /og/ must map back to a post's cover. +// 2. An SVG cover's text is checked against the bundled faces +// (scripts/lib/og-fonts.mjs). A character no face has would be drawn as +// an empty box in the share image, where nobody looks after merge, so it +// is named here instead, with the cover and the line it is in. +// 3. Render, onto white, as JPEG quality 82 with 4:4:4 chroma so the coloured +// text in the diagrams keeps clean edges: +// - an SVG cover is a diagram whose text can sit at any edge (two put a +// caption in the bottom 23px a centre crop would cut), so it is drawn +// whole, as vectors, at the height of the card (a 16:9 cover becomes +// 1120x630) and widened to 1200 by repeating its edge columns. No SVG +// cover draws anything in its outer 40px columns, which are background; +// - a PNG or JPG cover is an illustration with no text, so it is scaled +// to cover 1200x630 and cropped centred (a 16:9 cover loses 3.4% top and +// bottom, a 1.6:1 one 8%). +// 4. Every page whose `og:image` is a file of this site (a rendered one, or +// the default in src/layouts/Base.astro) declares its width and height, +// and they are the file's own; a file that is not in dist/ fails too. + +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { availableParallelism, tmpdir } from 'node:os'; +import path from 'node:path'; +import { argv, cwd, env, execPath, exit, stdin, stdout } from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { prepareOgFonts, uncoveredChars } from './lib/og-fonts.mjs'; +import { OG_IMAGE, ogImagePath, postCoverFile } from './lib/og-image.mjs'; + +const ROOT = cwd(); +const DIST = path.join(ROOT, 'dist'); +const BLOG = path.join(ROOT, 'content', 'blog'); +const SITE = 'https://www.objectos.ai'; +const VERBOSE = argv.includes('--verbose'); + +/** + * Render one share image into dist/. Runs in a worker process (see below). + * + * @param {import('sharp')} sharp + * @param {{ urlPath: string, cover: string }} job + */ +async function renderShareImage(sharp, { urlPath, cover }) { + let image; + if (cover.endsWith('.svg')) { + const { width, height } = await sharp(cover).metadata(); + const scale = Math.min(OG_IMAGE.width / width, OG_IMAGE.height / height); + const drawn = await sharp(cover, { density: 72 * scale }).png().toBuffer({ resolveWithObject: true }); + const w = Math.min(drawn.info.width, OG_IMAGE.width); + const h = Math.min(drawn.info.height, OG_IMAGE.height); + const left = Math.floor((OG_IMAGE.width - w) / 2); + const top = Math.floor((OG_IMAGE.height - h) / 2); + image = sharp(drawn.data) + .extract({ left: 0, top: 0, width: w, height: h }) + .extend({ left, right: OG_IMAGE.width - w - left, top, bottom: OG_IMAGE.height - h - top, extendWith: 'copy' }); + } else { + image = sharp(cover).resize(OG_IMAGE.width, OG_IMAGE.height, { fit: 'cover', position: 'centre' }); + } + const out = path.join(DIST, urlPath); + mkdirSync(path.dirname(out), { recursive: true }); + const info = await sharp(await image.png().toBuffer()) + .flatten({ background: '#ffffff' }) + .jpeg({ quality: 82, mozjpeg: true, chromaSubsampling: '4:4:4' }) + .toFile(out); + return info.size; +} + +// ─── Worker mode ──────────────────────────────────────────────────────────── +// +// SVG text is drawn by librsvg, Pango and fontconfig, and drawing several +// SVGs at once on libvips' threads in one process crashed it: a SIGSEGV or a +// bare "svgload: SVG rendering failed" in 4 of 6 runs, also after a first +// render had set the shared state up. One image at a time per process never +// failed. So the renders are spread over a few worker processes, each with +// its own fontconfig, each drawing one image at a time. +if (argv.includes('--render-worker')) { + let input = ''; + for await (const chunk of stdin) input += chunk; + const sharp = (await import('sharp')).default; + sharp.concurrency(1); + for (const job of JSON.parse(input)) { + const bytes = await renderShareImage(sharp, job); + await new Promise((resolve) => stdout.write(`${JSON.stringify({ urlPath: job.urlPath, bytes })}\n`, resolve)); + } + exit(0); +} + +const issues = []; + +const walk = (dir, keep) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full, keep); + return entry.isFile() && keep(entry.name) ? [full] : []; + }); + +// ─── 1. The share images the built pages name ─────────────────────────────── + +try { + statSync(DIST); +} catch { + console.error('x dist/ does not exist: run `astro build` first'); + exit(1); +} + +const attr = (tag, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(tag)?.[1]; +/** @type {Map>} URL path -> pages naming it */ +const named = new Map(); +/** @type {{ page: string, path: string, width?: string, height?: string }[]} */ +const declared = []; +const htmlFiles = walk(DIST, (name) => name.endsWith('.html')); +for (const file of htmlFiles) { + const page = path.relative(DIST, file).split(path.sep).join('/'); + const html = readFileSync(file, 'utf8'); + const head = html.slice(0, Math.max(0, html.indexOf(''))); + const metas = head.match(/]*>/g) ?? []; + const meta = (key, value) => { + const tag = metas.find((t) => attr(t, key) === value); + return tag ? attr(tag, 'content') : undefined; + }; + const urls = [meta('property', 'og:image'), meta('name', 'twitter:image')]; + for (const [, url] of head.matchAll(/"image":"([^"]+)"/g)) urls.push(url); + for (const url of urls) { + if (!url?.startsWith(`${SITE}/og/`)) continue; + const urlPath = url.slice(SITE.length); + if (!named.has(urlPath)) named.set(urlPath, new Set()); + named.get(urlPath).add(page); + } + // Every share image this site serves, the rendered ones and the default + // (src/layouts/Base.astro), is checked against the size the page declares. + const og = meta('property', 'og:image'); + if (og?.startsWith(`${SITE}/`)) { + declared.push({ + page, + path: og.slice(SITE.length), + width: meta('property', 'og:image:width'), + height: meta('property', 'og:image:height'), + }); + } +} + +// Every post's cover, by the share-image URL it maps to. +/** @type {Map} URL path -> cover file */ +const covers = new Map(); +for (const mdx of walk(BLOG, (name) => name.endsWith('.mdx'))) { + const cover = postCoverFile(mdx, ROOT); + if (cover) covers.set(ogImagePath(cover, ROOT), cover); +} + +const jobs = []; +for (const [urlPath, pages] of named) { + const cover = covers.get(urlPath); + if (!cover) { + issues.push(`${urlPath}: named by ${[...pages][0]} (and ${pages.size - 1} more), but no post's cover maps to it`); + continue; + } + jobs.push({ urlPath, cover }); +} + +// ─── 2. Fonts, and the glyph check ────────────────────────────────────────── + +const scratch = mkdtempSync(path.join(tmpdir(), 'og-images-')); +let summary = ''; +try { + const { conf, faces } = await prepareOgFonts(scratch); + + for (const job of jobs) { + if (!job.cover.endsWith('.svg')) continue; + for (const gap of uncoveredChars(readFileSync(job.cover, 'utf8'), faces)) { + issues.push( + `${path.relative(ROOT, job.cover)}: ${gap.codePoint} "${gap.char}" (in "${gap.text}") is in none of the ` + + `bundled faces and would render as an empty box; add a face that has it to scripts/lib/og-fonts.mjs` + ); + } + } + + // ─── 3. Render ────────────────────────────────────────────────────────────── + + const started = Date.now(); + const workers = Math.max(1, Math.min(3, availableParallelism() - 1, jobs.length)); + const shares = Array.from({ length: workers }, () => []); + // The SVGs are the slow ones; deal them round-robin so each worker gets its share. + [...jobs] + .sort((a, b) => Number(b.cover.endsWith('.svg')) - Number(a.cover.endsWith('.svg'))) + .forEach((job, i) => shares[i % workers].push(job)); + const rendered = new Map(); + await Promise.all( + shares.map( + (share) => + new Promise((resolve) => { + // fontconfig reads its configuration once, at first use: the worker + // gets the bundled fonts.conf in its environment before it starts. + const child = spawn(execPath, [fileURLToPath(import.meta.url), '--render-worker'], { + cwd: ROOT, + env: { ...env, FONTCONFIG_FILE: conf }, + stdio: ['pipe', 'pipe', 'inherit'], + }); + let out = ''; + child.stdout.on('data', (chunk) => (out += chunk)); + child.on('close', (code, signal) => { + for (const line of out.split('\n').filter(Boolean)) { + const { urlPath, bytes } = JSON.parse(line); + rendered.set(urlPath, bytes); + if (VERBOSE) console.log(` ${urlPath} ${bytes} B`); + } + if (code !== 0) { + const lost = share.filter((job) => !rendered.has(job.urlPath)).map((job) => job.urlPath); + issues.push(`a render worker exited with ${signal ?? `code ${code}`}; not rendered: ${lost.join(', ')}`); + } + resolve(); + }); + child.stdin.end(JSON.stringify(share)); + }) + ) + ); + const bytes = [...rendered.values()].reduce((sum, n) => sum + n, 0); + + // ─── 4. The size each page declares is the file's ────────────────────────── + + const sharp = (await import('sharp')).default; + const sizes = new Map(); + for (const { page, path: urlPath, width, height } of declared) { + if (!sizes.has(urlPath)) { + try { + const { width: w, height: h } = await sharp(path.join(DIST, urlPath)).metadata(); + sizes.set(urlPath, { w, h }); + } catch { + sizes.set(urlPath, null); + issues.push(`${page}: og:image ${urlPath} is not a file in dist/`); + } + } + const size = sizes.get(urlPath); + if (!size) continue; + if (width !== String(size.w) || height !== String(size.h)) { + issues.push( + `${page}: og:image ${urlPath} is ${size.w}x${size.h}, but og:image:width/height say ${width ?? '(none)'}x${height ?? '(none)'}` + ); + } + } + + summary = + `✓ share images: ${rendered.size} rendered at ${OG_IMAGE.width}x${OG_IMAGE.height} ` + + `(${(bytes / 1024 / 1024).toFixed(1)} MB, ${((Date.now() - started) / 1000).toFixed(1)} s, ${workers} workers) ` + + `for ${declared.length} pages; ${faces.length} bundled font files, no uncovered glyph`; +} finally { + rmSync(scratch, { recursive: true, force: true }); +} + +if (issues.length > 0) { + for (const issue of issues) console.error(`x ${issue}`); + console.error(`\nShare images failed (${issues.length} issue${issues.length === 1 ? '' : 's'})`); + exit(1); +} +console.log(summary); diff --git a/public/images/objectos-home-hero.png b/src/assets/objectos-home-hero.png similarity index 100% rename from public/images/objectos-home-hero.png rename to src/assets/objectos-home-hero.png diff --git a/src/components/ArticleCard.astro b/src/components/ArticleCard.astro index f1bd68a..57e720b 100644 --- a/src/components/ArticleCard.astro +++ b/src/components/ArticleCard.astro @@ -1,4 +1,5 @@ --- +import { Image } from 'astro:assets'; import type { Locale } from '../lib/i18n'; import { formatDate, postPath, STATUS_COLOR, ui } from '../lib/i18n'; import type { LocalePost } from '../lib/posts'; @@ -13,24 +14,50 @@ interface Props { const { post, locale, variant = 'standard' } = Astro.props; const d = post.entry.data; const t = ui[locale]; -const coverSrc = (cover: unknown): string => - typeof cover === 'string' - ? cover - : (cover as { src?: string })?.src ?? ''; +// A raster cover is served as WebP at the thumbnail's width, not as the +// 1.5-1.9 MB original: a standard card is one of three columns (about 343px) +// on a desktop, one of two at 641-900px and the full column on a phone; the +// feature card's cover column is about 585px at 1024px and up, and the full +// column below. `sizes` is the width the image is drawn at, not the card's: +// the 16:10 thumb crops a 16:9 cover with object-fit: cover, which draws it +// 1.11x the card's width (a 343px card needs 381px of image; the phone's 2:1 +// thumb needs none). The widest candidates cover the phone column at 3x and +// the feature at 2x. An SVG cover is a few KB and scales itself, so it is +// served as is. The feature card opens the blog index above the fold, so its +// cover is not lazy. +const THUMB_WIDTHS = [400, 540, 720, 1080, 1280]; +const thumbSizes = + variant === 'feature' + ? '(min-width: 1024px) 640px, calc(100vw - 2rem)' + : '(max-width: 640px) calc(100vw - 2rem), (max-width: 900px) calc(55vw - 2rem), 400px'; +const thumbLoading = variant === 'feature' ? 'eager' : 'lazy'; ---
  • {d.cover ? ( - {d.title} + d.cover.format === 'svg' ? ( + {d.title} + ) : ( + {d.title} + ) ) : (
    ◆ diff --git a/src/components/ClusterPage.astro b/src/components/ClusterPage.astro index 1848489..0586241 100644 --- a/src/components/ClusterPage.astro +++ b/src/components/ClusterPage.astro @@ -1,4 +1,5 @@ --- +import { Image } from 'astro:assets'; import type { CollectionEntry } from 'astro:content'; import type { Cluster } from '../lib/clusters'; import { clusterCopy, clusterLabels } from '../lib/cluster-i18n'; @@ -33,10 +34,16 @@ const termLinks = ( }) ) ).filter((link) => link !== undefined); -const coverSrc = (cover: unknown): string => - typeof cover === 'string' - ? cover - : (cover as { src?: string })?.src ?? ''; +// A raster cover is served as WebP at the width the reading-path card draws +// it at, not as the 240-370 KB original JPG. Above 640px the image is a 180px +// (150px at 641-900px) column as tall as the card's text, about 190-230px, and +// object-fit: cover fills it by height, so a 1.6:1 or 16:9 cover is drawn +// 300-370px wide: `sizes` says 400px. On a phone the card stacks and the image +// is the full column at 16:9, drawn at its own width. The widest candidate +// covers the phone column at 3x. An SVG cover is a few KB and scales itself, +// so it is served as is. +const CARD_WIDTHS = [400, 540, 720, 1080]; +const CARD_SIZES = '(max-width: 640px) calc(100vw - 2rem), 400px'; ---
    @@ -93,16 +100,28 @@ const coverSrc = (cover: unknown): string =>
    {posts.map(({ slug, entry }) => ( - {entry.data.cover && ( - {entry.data.title} - )} + {entry.data.cover && + (entry.data.cover.format === 'svg' ? ( + {entry.data.title} + ) : ( + {entry.data.title} + ))}
    - ObjectOS product surface connecting business data, applications, and AI agents
    {ui.loop.writer.eyebrow} @@ -310,6 +322,13 @@ const localizeHref = (href: string): string => { border-radius: 7px; background: var(--surface-sunk); } + /* Dark theme: the hero is a white product render, which stayed a glaring + white block on the dark page. The image itself is dimmed (no dark variant + of the render exists); the hues and the depth survive, the white does + not dominate the panel. */ + :global(:root[data-theme='dark']) .hero-snapshot { + filter: brightness(0.6) contrast(1.12); + } .loop-step { min-height: 124px; padding: 0.85rem; diff --git a/src/components/MarketingHome.astro b/src/components/MarketingHome.astro index e47da2a..27d5124 100644 --- a/src/components/MarketingHome.astro +++ b/src/components/MarketingHome.astro @@ -1,4 +1,6 @@ --- +import { Image } from 'astro:assets'; +import heroImage from '../assets/objectos-home-hero.png'; import { getPostsInLocale } from '../lib/posts'; import { blogPath, formatDate, pricingPath, postPath, ui, type Locale } from '../lib/i18n'; import { homeCopy } from '../lib/home'; @@ -80,10 +82,18 @@ const compareLinks = [ { slug: 'is-lovable-safe-for-production', tone: 'amber' }, ]; -const coverSrc = (cover: unknown): string => - typeof cover === 'string' - ? cover - : (cover as { src?: string })?.src ?? ''; +// A raster post cover is served as WebP at the card's width, not as the +// 1.5-1.9 MB original: one of three columns (about 345px) on a desktop, one of +// two at 641-900px, the full column on a phone. The widest candidate covers +// the phone column at 3x. An SVG cover is a few KB and is served as is. +const ARTICLE_WIDTHS = [360, 540, 720, 1080]; +const ARTICLE_SIZES = '(max-width: 640px) calc(100vw - 2rem), (max-width: 900px) calc(50vw - 2rem), 360px'; +// The product diagram under the positioning copy, as WebP instead of the +// 1.7 MB PNG. Its box is at most 850px wide at 3:2 (4:3 on a phone), and +// object-fit: cover draws the 1.6:1 image about 1.07x (1.2x) the box's width. +// It sits below the hero and the video at every viewport, so it stays lazy. +const HERO_WIDTHS = [480, 720, 960, 1280, 1586]; +const HERO_SIZES = '(min-width: 898px) 910px, calc(120vw - 2.4rem)'; ---
    @@ -132,11 +142,12 @@ const coverSrc = (cover: unknown): string => page's first visual now, so this sits below it, illustrating the positioning it belongs to. */}
    - {copy.imageAlt} @@ -310,16 +321,28 @@ const coverSrc = (cover: unknown): string =>
    {posts.map((post) => ( - {post.entry.data.cover && ( - {post.entry.data.title} - )} + {post.entry.data.cover && + (post.entry.data.cover.format === 'svg' ? ( + {post.entry.data.title} + ) : ( + {post.entry.data.title} + ))}