diff --git a/apps/sim/app/(landing)/og-utils.test.tsx b/apps/sim/app/(landing)/og-utils.test.tsx new file mode 100644 index 00000000000..df1b048acc2 --- /dev/null +++ b/apps/sim/app/(landing)/og-utils.test.tsx @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createLandingOgImage } from '@/app/(landing)/og-utils' + +/** + * Renders a real PNG. The bundled fonts are the point: Satori throws + * "No fonts are loaded" if it receives an empty `fonts` array, which is how a + * failed Google Fonts fetch used to take the whole build down. + */ +describe('landing OG image', () => { + it('renders a PNG using the bundled Geist fonts', async () => { + const response = await createLandingOgImage({ + eyebrow: 'Sim integration', + title: 'Mintlify Integration', + subtitle: 'Deploy, edit, search, and measure Mintlify documentation', + pills: ['12 tools', 'API key auth', 'Free to start'], + domainLabel: 'sim.ai/integrations/mintlify', + }) + + expect(response.status).toBe(200) + const bytes = new Uint8Array(await response.arrayBuffer()) + expect(bytes.byteLength).toBeGreaterThan(1000) + // PNG magic number — proves Satori laid the text out and resvg rasterized it. + expect(Array.from(bytes.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]) + }, 30_000) +}) diff --git a/apps/sim/app/(landing)/og-utils.tsx b/apps/sim/app/(landing)/og-utils.tsx index 9eaf8beb1a8..7f730859d3e 100644 --- a/apps/sim/app/(landing)/og-utils.tsx +++ b/apps/sim/app/(landing)/og-utils.tsx @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { ImageResponse } from 'next/og' import { SimLogoFull } from '@/app/(landing)/components/og-sim-logo' @@ -18,28 +20,37 @@ function getTitleFontSize(title: string): number { return TITLE_FONT_SIZE.large } -async function loadGoogleFont( - font: string, - weights: string, - text: string -): Promise { - try { - const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}` - const css = await (await fetch(url)).text() - const resource = css.match(/src: url\(([^)]+)\) format\('(opentype|truetype|woff2?)'\)/) +/** + * Geist, read from the repo rather than fetched from Google Fonts. + * + * Satori requires at least one font and throws if it gets none, so a fetch that + * returned nothing took the whole build down with "No fonts are loaded" on + * whichever page happened to be rendering. That was not a rare race: six routes + * build an OG image, `integrations/[slug]` alone is 237 pages, and each render + * fetched two weights subsetted by `&text=` — a per-page URL no cache can reuse. + * Several hundred uncacheable requests to one host, from one CI egress IP, in + * parallel across build workers. + * + * Read once at module scope, per Next's `ImageResponse` guidance. `.ttf` + * because Satori accepts only ttf/otf/woff — the sibling `.woff2` the app + * serves to browsers cannot be reused here. + * + * These live under `public/` so they need no `outputFileTracingIncludes` entry: + * `docker/app.Dockerfile` copies that directory into the runner, which the + * `force-dynamic` share-token card needs since it renders per request. + * + * `process.cwd()` is the app directory in every environment this runs in, not + * just dev and build. The container starts at the monorepo root, but Next's + * generated standalone `server.js` opens with `process.chdir(__dirname)`, and + * that file ships beside `public/` — which is also why `content/` is read this + * way at runtime. + */ +const FONT_DIR = join(process.cwd(), 'public', 'brand', 'fonts') - if (resource) { - const response = await fetch(resource[1]) - if (response.status === 200) { - return await response.arrayBuffer() - } - } - } catch { - return null - } - - return null -} +const [geistRegular, geistMedium] = await Promise.all([ + readFile(join(FONT_DIR, 'Geist-Regular.ttf')), + readFile(join(FONT_DIR, 'Geist-Medium.ttf')), +]) interface LandingOgImageProps { eyebrow: string @@ -57,12 +68,6 @@ export async function createLandingOgImage({ pills = [], domainLabel = 'sim.ai', }: LandingOgImageProps) { - const text = `${eyebrow}${title}${subtitle}${pills.join('')}${domainLabel}` - const [regularFontData, mediumFontData] = await Promise.all([ - loadGoogleFont('Geist', '400', text), - loadGoogleFont('Geist', '500', text), - ]) - return new ImageResponse(