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, + }, + ], +}