From 0c3a9c448f52ef93188fa835421dd048729aa2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 11 Oct 2026 09:55:17 +0200 Subject: [PATCH] feat: pre-generate og image OG images are broken at the moment due to satori dependency changes. Already before it was quite hard to make everything work well in the Cloudflare workers runtime. Update the OG images setup to generate all images during build step and then serve them as static assets instead of generating them on the fly. --- astro.config.ts | 2 - package-lock.json | 238 +++++++++++++++++++++++++++++++++- package.json | 6 +- public/_headers | 3 + scripts/check-og-images.mjs | 65 ++++++++++ src/env.d.ts | 5 - src/pages/og/[...slog].png.ts | 90 ++++++++----- src/utils/ogImageResponse.ts | 29 +---- wrangler.jsonc | 21 ++- 9 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 scripts/check-og-images.mjs diff --git a/astro.config.ts b/astro.config.ts index cc182f3a..7071ec9e 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -210,13 +210,11 @@ export default defineConfig({ vite: { plugins: [rawFonts([".woff2", ".woff", ".ttf", ".otf"])], - assetsInclude: ["**/*.wasm"], // Treat WASM files as assets (but not font files used by OG) resolve: { noExternal: ["@sumup-oss/circuit-ui", "@sumup-oss/illustrations"], }, ssr: { external: ["buffer", "path", "fs"].map((i) => `node:${i}`), - noExternal: ["workers-og"], }, }, diff --git a/package-lock.json b/package-lock.json index 13e8a908..2498c7f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@eslint/js": "9.39.5", "@expressive-code/plugin-collapsible-sections": "^0.44.2", "@expressive-code/plugin-line-numbers": "0.44.2", - "@resvg/resvg-wasm": "2.6.2", + "@resvg/resvg-js": "2.6.2", "@sindresorhus/slugify": "^3.0.1", "@sumup-oss/circuit-ui": "12.1.0", "@sumup-oss/design-tokens": "11.0.0", @@ -4387,12 +4387,242 @@ "dev": true, "license": "MIT" }, - "node_modules/@resvg/resvg-wasm": { + "node_modules/@resvg/resvg-js": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz", - "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", "dev": true, "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10" } diff --git a/package.json b/package.json index acf4dd75..e6ac2c50 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "start": "astro preview", "prebuild": "astro sync --force", "build": "astro build", + "postbuild": "npm run check:og-images", + "check:og-images": "node scripts/check-og-images.mjs", "precheck": "astro sync", "check": "astro check", "prelint": "astro sync", @@ -51,7 +53,7 @@ "lint:src:fix": "npx eslint --fix", "lint:markdown": "rumdl check .", "lint:markdown:fix": "rumdl fmt .", - "linkcheck": "CHECK_LINKS=true astro build && npm run linkcheck:rendered", + "linkcheck": "CHECK_LINKS=true astro build && npm run check:og-images && npm run linkcheck:rendered", "linkcheck:rendered": "node scripts/check-rendered-links.mjs", "test": "vitest run" }, @@ -70,7 +72,7 @@ "@eslint/js": "9.39.5", "@expressive-code/plugin-collapsible-sections": "^0.44.2", "@expressive-code/plugin-line-numbers": "0.44.2", - "@resvg/resvg-wasm": "2.6.2", + "@resvg/resvg-js": "2.6.2", "@sindresorhus/slugify": "^3.0.1", "@sumup-oss/circuit-ui": "12.1.0", "@sumup-oss/design-tokens": "11.0.0", diff --git a/public/_headers b/public/_headers index 65e59ee7..c620732f 100644 --- a/public/_headers +++ b/public/_headers @@ -10,6 +10,9 @@ /img/* Cache-Control: public, max-age=604800 +/og/* + Cache-Control: public, max-age=3600, must-revalidate + /*.md X-Robots-Tag: noindex diff --git a/scripts/check-og-images.mjs b/scripts/check-og-images.mjs new file mode 100644 index 00000000..3d8b0888 --- /dev/null +++ b/scripts/check-og-images.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { JSDOM } from "jsdom"; + +const outputRoot = resolve(process.argv[2] ?? "dist/client"); +const siteOrigin = "https://developer.sumup.com"; + +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }), + ) + ).flat(); +} + +const files = await listFiles(outputRoot); +const images = files.filter( + (file) => + file.startsWith(`${join(outputRoot, "og")}/`) && file.endsWith(".png"), +); +assert(images.length > 0, "No generated OG images found"); + +for (const image of images) { + const png = await readFile(image); + const name = relative(outputRoot, image); + assert( + png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + `${name}: invalid PNG signature`, + ); + assert.equal( + png.toString("ascii", 12, 16), + "IHDR", + `${name}: missing PNG header`, + ); + assert.equal(png.readUInt32BE(16), 1200, `${name}: incorrect width`); + assert.equal(png.readUInt32BE(20), 600, `${name}: incorrect height`); +} + +const generatedImages = new Set(images); +let checkedPages = 0; +for (const file of files.filter((file) => file.endsWith(".html"))) { + const dom = new JSDOM(await readFile(file, "utf8")); + const imageUrl = dom.window.document + .querySelector('meta[property="og:image"]') + ?.getAttribute("content"); + dom.window.close(); + if (!imageUrl) continue; + + const url = new URL(imageUrl, siteOrigin); + assert.equal(url.origin, siteOrigin, `${file}: unexpected OG image origin`); + assert( + generatedImages.has(join(outputRoot, decodeURIComponent(url.pathname))), + `${relative(outputRoot, file)}: missing generated image ${url.pathname}`, + ); + checkedPages++; +} +assert(checkedPages > 0, "No pages referencing OG images found"); +console.log( + `Verified ${images.length} OG PNGs and references from ${checkedPages} pages.`, +); diff --git a/src/env.d.ts b/src/env.d.ts index b9560e3b..c4f58f0d 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -12,8 +12,3 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } - -declare module "*.wasm" { - const module: WebAssembly.Module; - export default module; -} diff --git a/src/pages/og/[...slog].png.ts b/src/pages/og/[...slog].png.ts index 2c01a9a4..04b6d10c 100644 --- a/src/pages/og/[...slog].png.ts +++ b/src/pages/og/[...slog].png.ts @@ -1,5 +1,8 @@ import { getCollection } from "astro:content"; import { createElement } from "react"; +import slugify from "@sindresorhus/slugify"; +import { apiTopLevelPaths } from "../../lib/openapi/routes"; +import { orderedTagEntriesCached } from "../../lib/openapi/tags"; import SumUpBlackData from "../../assets/fonts/sumup-black-latin-s.ttf"; import SumUpNarrowMediumData from "../../assets/fonts/sumup-narrow-latin-s-medium.ttf"; @@ -7,10 +10,10 @@ import SumUpNarrowRegularData from "../../assets/fonts/sumup-narrow-latin-s-regu import { createOgImageResponse } from "../../utils/ogImageResponse"; interface Props { - params: { slog?: string }; + props: { title: string; description: string | null }; } -export const prerender = false; +export const prerender = true; // Overrides for pages that are not backend by content collections. const staticPageMetadata = new Map< @@ -88,52 +91,67 @@ function createFontStack(primary: string | null) { : "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; } -async function resolvePageMetadata(path: string) { - const normalizedPath = path.replace(/^\/+|\/+$/g, ""); +export async function getStaticPaths() { const defaultMetadata = staticPageMetadata.get("")!; const changelogMetadata = staticPageMetadata.get("changelog")!; - const docs = await getCollection("docs"); - const doc = docs.find((entry) => entry.id === (normalizedPath || "index")); - if (doc) { - return { - title: doc.data.title, - description: doc.data.description ?? null, - }; + const changelog = await getCollection("changelog"); + const metadata = new Map(staticPageMetadata); + + // Preserve the existing default cards for custom API and 404 pages. + for (const path of [ + "index", + "404", + "api", + ...apiTopLevelPaths.map((path) => `api/${path}`), + ]) { + metadata.set(path, defaultMetadata); } - if ( - normalizedPath === "changelog" || - normalizedPath.startsWith("changelog/") - ) { - return changelogMetadata; + for (const [tag, operations] of orderedTagEntriesCached) { + metadata.set(`api/${tag}`, defaultMetadata); + for (const operation of operations) { + metadata.set(`api/${tag}/${operation.slug}`, defaultMetadata); + } } - const staticData = staticPageMetadata.get(normalizedPath); - if (staticData) { - return staticData; + for (const entry of changelog) { + metadata.set(`changelog/${entry.id}`, changelogMetadata); + for (const tag of entry.data.tags) { + metadata.set(`changelog/tags/${slugify(tag)}`, changelogMetadata); + } } - return defaultMetadata; -} + for (const doc of docs) { + metadata.set(doc.id, { + title: doc.data.title, + description: doc.data.description ?? null, + }); + } + metadata.set("", metadata.get("index")!); -export async function GET({ params }: Props) { - const { title, description } = await resolvePageMetadata(params.slog ?? ""); + return [...metadata].map(([slog, props]) => ({ + params: { slog: slog || undefined }, + props, + })); +} - const sumUpBlack = toSupportedFontData(SumUpBlackData, "SumUp Black"); - const sumUpNarrowMedium = toSupportedFontData( - SumUpNarrowMediumData, - "SumUp Narrow Medium", - ); - const sumUpNarrowRegular = toSupportedFontData( - SumUpNarrowRegularData, - "SumUp Narrow Regular", - ); - const blackFontFamily = createFontStack(sumUpBlack ? "SumUp Black" : null); - const narrowFontFamily = createFontStack( - sumUpNarrowRegular || sumUpNarrowMedium ? "SumUp Narrow" : null, - ); +// Reuse font buffers so Satori can cache parsed fonts across generated cards. +const sumUpBlack = toSupportedFontData(SumUpBlackData, "SumUp Black"); +const sumUpNarrowMedium = toSupportedFontData( + SumUpNarrowMediumData, + "SumUp Narrow Medium", +); +const sumUpNarrowRegular = toSupportedFontData( + SumUpNarrowRegularData, + "SumUp Narrow Regular", +); +const blackFontFamily = createFontStack(sumUpBlack ? "SumUp Black" : null); +const narrowFontFamily = createFontStack( + sumUpNarrowRegular || sumUpNarrowMedium ? "SumUp Narrow" : null, +); +export async function GET({ props: { title, description } }: Props) { const card = createElement( "div", { diff --git a/src/utils/ogImageResponse.ts b/src/utils/ogImageResponse.ts index 5b6d0fdb..da9e3ec3 100644 --- a/src/utils/ogImageResponse.ts +++ b/src/utils/ogImageResponse.ts @@ -1,8 +1,6 @@ -import { Resvg, initWasm } from "@resvg/resvg-wasm"; +import { Resvg } from "@resvg/resvg-js"; import satori from "satori"; -import resvgWasm from "@resvg/resvg-wasm/index_bg.wasm"; - import type { ReactNode } from "react"; import type { Font, SatoriOptions } from "satori"; @@ -16,33 +14,10 @@ type OgImageResponseOptions = { statusText?: string; }; -let resvgInitPromise: Promise | undefined; -let resvgInitialized = false; - -async function ensureResvg() { - if (resvgInitialized) { - return; - } - - resvgInitPromise ??= initWasm(resvgWasm).then( - () => { - resvgInitialized = true; - }, - (error: unknown) => { - resvgInitPromise = undefined; - throw error; - }, - ); - - await resvgInitPromise; -} - export async function createOgImageResponse( element: ReactNode, options: OgImageResponseOptions, ) { - await ensureResvg(); - const svg = await satori(element, { width: options.width, height: options.height, @@ -67,7 +42,7 @@ export async function createOgImageResponse( "Content-Type": "image/png", "Cache-Control": options.debug ? "no-cache, no-store" - : "public, immutable, no-transform, max-age=31536000", + : "public, max-age=3600, must-revalidate", ...options.headers, }, status: options.status ?? 200, diff --git a/wrangler.jsonc b/wrangler.jsonc index 06085ffc..1b331507 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,9 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "developer-sumup-com", "compatibility_date": "2026-05-26", - "compatibility_flags": [ - "nodejs_compat" - ], + "compatibility_flags": ["nodejs_compat"], "workers_dev": true, "preview_urls": true, "upload_source_maps": true, @@ -12,22 +10,19 @@ "directory": "./dist", "not_found_handling": "404-page", "html_handling": "drop-trailing-slash", - "run_worker_first": [ - "/contact", - "/og/*" - ], + "run_worker_first": ["/contact"], }, "observability": { "enabled": true, "logs": { "enabled": true, - "invocation_logs": true - } + "invocation_logs": true, + }, }, "routes": [ { "pattern": "developer.sumup.com", - "custom_domain": true - } - ] -} \ No newline at end of file + "custom_domain": true, + }, + ], +}