|
| 1 | +import { readFile } from 'node:fs/promises' |
| 2 | +import { join } from 'node:path' |
| 3 | +import type { CSSProperties } from 'react' |
| 4 | +import { ImageResponse } from 'next/og' |
| 5 | + |
| 6 | +/** |
| 7 | + * The brandbook cover template, rendered on demand. |
| 8 | + * |
| 9 | + * One template backs every social card the site generates: light gray field, |
| 10 | + * the "sim" wordmark top-left, a diagonal open arrow top-right, and the title |
| 11 | + * set large at the bottom-left. Library post covers are the build-time |
| 12 | + * rendering of it (`scripts/generate-library-covers.tsx`), docs pages the |
| 13 | + * edge-runtime one (`apps/docs/app/api/og/route.tsx`). This is the Node |
| 14 | + * rendering, for `apps/sim` routes that resolve their title per request. |
| 15 | + */ |
| 16 | + |
| 17 | +const COVER_WIDTH = 1200 |
| 18 | +const COVER_HEIGHT = 675 |
| 19 | + |
| 20 | +/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */ |
| 21 | +const INK_COLOR = '#515151' |
| 22 | +const BACKGROUND_COLOR = '#c1c1c1' |
| 23 | +/** The title's ink, dropped back so a secondary line reads as caption rather than headline. */ |
| 24 | +const MUTED_INK_COLOR = 'rgba(81, 81, 81, 0.72)' |
| 25 | + |
| 26 | +const TITLE_FONT_SIZE = { |
| 27 | + large: 110, |
| 28 | + medium: 96, |
| 29 | + small: 85, |
| 30 | +} as const |
| 31 | +const SUBTITLE_FONT_SIZE = 30 |
| 32 | +const TITLE_BOX_WIDTH = 1020 |
| 33 | +/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */ |
| 34 | +const CHAR_WIDTH_EM = 0.42 |
| 35 | + |
| 36 | +/** |
| 37 | + * Söhne Kräftig (weight 500), the typeface of the reference cover template, as |
| 38 | + * a plain TTF — Satori (the renderer behind `ImageResponse`) parses neither |
| 39 | + * WOFF2 nor variable fonts. |
| 40 | + * |
| 41 | + * Read once at module scope, per Next's `ImageResponse` guidance, from |
| 42 | + * `public/` so it needs no `outputFileTracingIncludes` entry: |
| 43 | + * `docker/app.Dockerfile` copies that directory into the runner, which |
| 44 | + * per-request cards need since they render outside the build. `process.cwd()` |
| 45 | + * is the app directory in every environment this runs in — Next's generated |
| 46 | + * standalone `server.js` opens with `process.chdir(__dirname)`, and that file |
| 47 | + * ships beside `public/`. See `app/(landing)/og-utils.tsx` for the longer |
| 48 | + * account of why these are not fetched. |
| 49 | + */ |
| 50 | +const titleFont = await readFile( |
| 51 | + join(process.cwd(), 'public', 'brand', 'fonts', 'Soehne-Kraftig.ttf') |
| 52 | +) |
| 53 | + |
| 54 | +const CONTAINER_STYLE = { |
| 55 | + height: '100%', |
| 56 | + width: '100%', |
| 57 | + display: 'flex', |
| 58 | + flexDirection: 'column', |
| 59 | + justifyContent: 'space-between', |
| 60 | + padding: '26px', |
| 61 | + background: BACKGROUND_COLOR, |
| 62 | + fontFamily: 'Soehne', |
| 63 | +} satisfies CSSProperties |
| 64 | +const HEADER_STYLE = { |
| 65 | + display: 'flex', |
| 66 | + justifyContent: 'space-between', |
| 67 | + alignItems: 'flex-start', |
| 68 | + width: '100%', |
| 69 | +} satisfies CSSProperties |
| 70 | +const FOOTER_STYLE = { |
| 71 | + display: 'flex', |
| 72 | + flexDirection: 'column', |
| 73 | + width: `${TITLE_BOX_WIDTH}px`, |
| 74 | + /** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */ |
| 75 | + transform: 'translateY(14px)', |
| 76 | +} satisfies CSSProperties |
| 77 | +const TITLE_STYLE = { |
| 78 | + display: 'flex', |
| 79 | + flexDirection: 'column', |
| 80 | + fontWeight: 500, |
| 81 | + color: INK_COLOR, |
| 82 | + lineHeight: 1.1, |
| 83 | +} satisfies CSSProperties |
| 84 | +const SUBTITLE_STYLE = { |
| 85 | + display: 'flex', |
| 86 | + marginTop: 18, |
| 87 | + fontSize: SUBTITLE_FONT_SIZE, |
| 88 | + fontWeight: 500, |
| 89 | + color: MUTED_INK_COLOR, |
| 90 | + lineHeight: 1.2, |
| 91 | +} satisfies CSSProperties |
| 92 | + |
| 93 | +function getTitleFontSize(title: string): number { |
| 94 | + if (title.length > 45) return TITLE_FONT_SIZE.small |
| 95 | + if (title.length > 30) return TITLE_FONT_SIZE.medium |
| 96 | + return TITLE_FONT_SIZE.large |
| 97 | +} |
| 98 | + |
| 99 | +function estimateWidthEm(text: string): number { |
| 100 | + return text.length * CHAR_WIDTH_EM |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Splits a single word wider than `maxWidthEm` into chunks that each fit. |
| 105 | + * |
| 106 | + * Hyphens are tried first because that is where a reader expects a compound to |
| 107 | + * break, and the trailing hyphen stays on the upper line. A chunk with no |
| 108 | + * usable hyphen falls back to a character-level split, which only a |
| 109 | + * pathological token reaches — and file names, the titles this renders, |
| 110 | + * supply plenty of them. |
| 111 | + */ |
| 112 | +function splitOversizedWord(word: string, maxWidthEm: number): string[] { |
| 113 | + const chunks: string[] = [] |
| 114 | + let chunk = '' |
| 115 | + |
| 116 | + const pieces = word.split(/(?<=-)/).flatMap((piece) => (piece.length > 1 ? [piece] : [...piece])) |
| 117 | + for (const piece of pieces) { |
| 118 | + const candidate = chunk + piece |
| 119 | + if (estimateWidthEm(candidate) > maxWidthEm && chunk) { |
| 120 | + chunks.push(chunk) |
| 121 | + chunk = piece |
| 122 | + } else { |
| 123 | + chunk = candidate |
| 124 | + } |
| 125 | + } |
| 126 | + if (chunk) chunks.push(chunk) |
| 127 | + |
| 128 | + return chunks.flatMap((entry) => |
| 129 | + estimateWidthEm(entry) > maxWidthEm ? splitByCharacter(entry, maxWidthEm) : [entry] |
| 130 | + ) |
| 131 | +} |
| 132 | + |
| 133 | +/** Last-resort break for a run with no hyphen to break on — a long URL, an unbroken identifier. */ |
| 134 | +function splitByCharacter(word: string, maxWidthEm: number): string[] { |
| 135 | + const chunks: string[] = [] |
| 136 | + let chunk = '' |
| 137 | + |
| 138 | + for (const char of word) { |
| 139 | + const candidate = chunk + char |
| 140 | + if (estimateWidthEm(candidate) > maxWidthEm && chunk) { |
| 141 | + chunks.push(chunk) |
| 142 | + chunk = char |
| 143 | + } else { |
| 144 | + chunk = candidate |
| 145 | + } |
| 146 | + } |
| 147 | + if (chunk) chunks.push(chunk) |
| 148 | + |
| 149 | + return chunks |
| 150 | +} |
| 151 | + |
| 152 | +/** |
| 153 | + * Replaces every plain space (U+0020) with U+00A0. Satori has a |
| 154 | + * text-measurement bug where the first plain space in a text node renders at |
| 155 | + * roughly double width — a non-breaking space measures correctly and reads |
| 156 | + * identically at these sizes, so it sidesteps the bug rather than fighting |
| 157 | + * Satori's own line-wrapping, which is disabled here since lines arrive |
| 158 | + * pre-split. |
| 159 | + */ |
| 160 | +function withHardSpaces(text: string): string { |
| 161 | + return text.replace(/ /g, '\u00a0') |
| 162 | +} |
| 163 | + |
| 164 | +/** Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`. */ |
| 165 | +function wrapTitleLines(title: string, fontSize: number): string[] { |
| 166 | + const maxWidthEm = TITLE_BOX_WIDTH / fontSize |
| 167 | + const lines: string[] = [] |
| 168 | + let current = '' |
| 169 | + |
| 170 | + for (const word of title.split(' ')) { |
| 171 | + if (estimateWidthEm(word) > maxWidthEm) { |
| 172 | + if (current) { |
| 173 | + lines.push(current) |
| 174 | + current = '' |
| 175 | + } |
| 176 | + const chunks = splitOversizedWord(word, maxWidthEm) |
| 177 | + lines.push(...chunks.slice(0, -1)) |
| 178 | + current = chunks[chunks.length - 1] ?? '' |
| 179 | + continue |
| 180 | + } |
| 181 | + |
| 182 | + const candidate = current ? `${current} ${word}` : word |
| 183 | + if (estimateWidthEm(candidate) > maxWidthEm && current) { |
| 184 | + lines.push(current) |
| 185 | + current = word |
| 186 | + } else { |
| 187 | + current = candidate |
| 188 | + } |
| 189 | + } |
| 190 | + if (current) lines.push(current) |
| 191 | + |
| 192 | + return lines.map(withHardSpaces) |
| 193 | +} |
| 194 | + |
| 195 | +/** "sim" wordmark, no icon — the brandbook wordmark geometry the docs navbar and library covers use. */ |
| 196 | +function SimWordmark() { |
| 197 | + return ( |
| 198 | + <svg width='118' height='57' viewBox='0 0 800 386' fill='none'> |
| 199 | + <path |
| 200 | + d='M0 293.75h53.4128c0 14.748 5.3413 26.506 16.0239 35.275 10.6826 8.37 25.1238 12.555 43.3233 12.555 19.783 0 35.016-3.786 45.698-11.36 10.683-7.971 16.024-18.534 16.024-31.687 0-9.566-2.967-17.538-8.902-23.915-5.539-6.378-15.826-11.559-30.861-15.545l-51.0389-11.958c-25.7173-6.377-44.9063-16.142-57.5672-29.296-12.2651-13.153-18.39771-30.491-18.39771-52.015 0-17.936 4.55001-33.481 13.64991-46.635 9.4957-13.153 22.3543-23.3169 38.576-30.4914 16.6173-7.1745 35.6086-10.7619 56.9739-10.7619 21.365 0 39.763 3.7866 55.193 11.3598 15.826 7.5731 28.091 18.1355 36.796 31.6875 9.1 13.552 13.847 29.695 14.243 48.428h-53.413c-.395-15.146-5.341-26.904-14.837-35.275-9.495-8.37-22.75-12.555-39.763-12.555-17.4083 0-30.8604 3.786-40.356 11.36-9.4956 7.573-14.2434 17.936-14.2434 31.089 0 19.531 14.2434 32.884 42.7304 40.058l51.039 12.556c24.53 5.58 42.928 14.747 55.193 27.502 12.265 12.356 18.398 29.296 18.398 50.82 0 18.335-4.946 34.477-14.837 48.428-9.891 13.552-23.541 24.114-40.95 31.687-17.013 7.175-37.191 10.762-60.534 10.762-34.0265 0-61.1285-8.37-81.3067-25.111-20.1782-16.74-30.2673-39.061-30.2673-66.962z' |
| 201 | + fill={INK_COLOR} |
| 202 | + /> |
| 203 | + <path |
| 204 | + d='m267.175 385.826v-292.3631c22.244 8.1331 32.053 8.1331 55.787 0v292.3631zm27.3-311.6891c-9.891 0-18.596-3.5872-26.113-10.7618-7.122-7.5731-10.683-16.342-10.683-26.3067 0-10.3632 3.561-19.132 10.683-26.3066 7.517-7.17453 16.222-10.7618 26.113-10.7618 10.287 0 18.991 3.58727 26.113 10.7618 7.122 7.1746 10.682 15.9434 10.682 26.3066 0 9.9647-3.56 18.7336-10.682 26.3067-7.122 7.1746-15.826 10.7618-26.113 10.7618z' |
| 205 | + fill={INK_COLOR} |
| 206 | + /> |
| 207 | + <path |
| 208 | + d='m421.362 385.823h-55.786v-292.3624h49.852v49.3294c5.934-16.342 17.408-30.197 33.234-40.959 16.222-11.1605 35.807-16.7407 58.754-16.7407 25.718 0 47.083 6.9752 64.096 20.9257 17.013 13.951 28.091 32.485 33.234 55.603h-10.089c3.957-23.118 14.837-41.652 32.642-55.603 17.804-13.9505 39.762-20.9257 65.875-20.9257 33.235 0 59.348 9.7653 78.339 29.2957 18.991 19.531 28.487 46.236 28.487 80.116v191.321h-54.6v-177.57c0-23.118-5.934-40.855-17.804-53.211-11.474-12.755-27.102-19.132-46.885-19.132-13.847 0-26.113 3.189-36.795 9.566-10.287 5.979-18.398 14.748-24.333 26.307-5.934 11.559-8.902 25.111-8.902 40.655v173.385h-55.193v-178.168c0-23.118-5.737-40.655-17.211-52.613-11.474-12.356-27.102-18.534-46.885-18.534-13.847 0-26.112 3.189-36.795 9.566-10.287 5.979-18.398 14.748-24.333 26.307-5.934 11.16-8.902 24.513-8.902 40.057z' |
| 209 | + fill={INK_COLOR} |
| 210 | + /> |
| 211 | + </svg> |
| 212 | + ) |
| 213 | +} |
| 214 | + |
| 215 | +/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */ |
| 216 | +function CornerArrow() { |
| 217 | + return ( |
| 218 | + <svg width='58' height='58' viewBox='0 0 24 24' fill='none'> |
| 219 | + <path |
| 220 | + d='M2 22 22 2M22 2H12M22 2V12' |
| 221 | + stroke={INK_COLOR} |
| 222 | + strokeWidth={3.6} |
| 223 | + strokeLinecap='square' |
| 224 | + strokeLinejoin='miter' |
| 225 | + /> |
| 226 | + </svg> |
| 227 | + ) |
| 228 | +} |
| 229 | + |
| 230 | +export const COVER_OG_SIZE = { width: COVER_WIDTH, height: COVER_HEIGHT } as const |
| 231 | + |
| 232 | +interface CoverOgImageProps { |
| 233 | + title: string |
| 234 | + /** Optional caption under the title — provenance, a byline, a section label. */ |
| 235 | + subtitle?: string |
| 236 | +} |
| 237 | + |
| 238 | +/** Renders the brandbook cover template for a single title. */ |
| 239 | +export function createCoverOgImage({ title, subtitle }: CoverOgImageProps) { |
| 240 | + const fontSize = getTitleFontSize(title) |
| 241 | + |
| 242 | + return new ImageResponse( |
| 243 | + <div style={CONTAINER_STYLE}> |
| 244 | + <div style={HEADER_STYLE}> |
| 245 | + <SimWordmark /> |
| 246 | + <CornerArrow /> |
| 247 | + </div> |
| 248 | + |
| 249 | + <div style={FOOTER_STYLE}> |
| 250 | + <div style={{ ...TITLE_STYLE, fontSize }}> |
| 251 | + {wrapTitleLines(title, fontSize).map((line, index) => ( |
| 252 | + <span key={index}>{line}</span> |
| 253 | + ))} |
| 254 | + </div> |
| 255 | + {subtitle ? <span style={SUBTITLE_STYLE}>{withHardSpaces(subtitle)}</span> : null} |
| 256 | + </div> |
| 257 | + </div>, |
| 258 | + { |
| 259 | + ...COVER_OG_SIZE, |
| 260 | + fonts: [{ name: 'Soehne', data: titleFont, style: 'normal' as const, weight: 500 as const }], |
| 261 | + } |
| 262 | + ) |
| 263 | +} |
0 commit comments