Skip to content

Commit e33f225

Browse files
committed
fix(og): put the shared-file card on the brandbook cover template
1 parent a27f376 commit e33f225

4 files changed

Lines changed: 312 additions & 14 deletions

File tree

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
1+
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
12
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
2-
import { createLandingOgImage } from '@/app/(landing)/og-utils'
33
import { buildProvenance } from '@/app/f/[token]/utils'
44

55
export const dynamic = 'force-dynamic'
66
export const contentType = 'image/png'
7-
export const size = {
8-
width: 1200,
9-
height: 630,
10-
}
7+
export const size = COVER_OG_SIZE
118

129
/**
13-
* Social-preview card for a shared file. Public shares show the file name +
14-
* provenance; protected (password / email / SSO) and unknown shares stay generic
15-
* so the filename never leaks pre-auth.
10+
* Social-preview card for a shared file, on the same brandbook cover template
11+
* as library posts and docs pages. Public shares show the file name +
12+
* provenance; protected (password / email / SSO) and unknown shares stay
13+
* generic so the filename never leaks pre-auth.
1614
*/
1715
export default async function Image({ params }: { params: Promise<{ token: string }> }) {
1816
const { token } = await params
1917
const resolved = await resolveActiveShareByToken(token)
2018

2119
if (!resolved || resolved.share.authType !== 'public') {
22-
return createLandingOgImage({
23-
eyebrow: 'Shared file',
20+
return createCoverOgImage({
2421
title: 'Protected file',
2522
subtitle: 'Authentication is required to view this file',
2623
})
2724
}
2825

2926
const { file, workspaceName, ownerName } = resolved
30-
const subtitle = buildProvenance(workspaceName, ownerName) || 'Shared via Sim'
3127

32-
return createLandingOgImage({
33-
eyebrow: 'Shared file',
28+
return createCoverOgImage({
3429
title: file.originalName,
35-
subtitle,
30+
subtitle: buildProvenance(workspaceName, ownerName) || 'Shared via Sim',
3631
})
3732
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { createCoverOgImage } from '@/lib/og/cover-image'
6+
7+
/**
8+
* Renders a real PNG. The font read at module scope is the point: it comes off
9+
* disk rather than the network, so a missing `public/brand/fonts` entry would
10+
* otherwise surface only as a broken card in production — Satori throws
11+
* "No fonts are loaded" when it receives an empty `fonts` array.
12+
*/
13+
describe('cover OG image', () => {
14+
const expectPng = async (response: Response) => {
15+
expect(response.status).toBe(200)
16+
const bytes = new Uint8Array(await response.arrayBuffer())
17+
expect(bytes.byteLength).toBeGreaterThan(1000)
18+
// PNG magic number — proves Satori laid the text out and resvg rasterized it.
19+
expect(Array.from(bytes.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
20+
}
21+
22+
it('renders a PNG using the bundled Söhne font', async () => {
23+
await expectPng(
24+
await createCoverOgImage({
25+
title: 'sim-cli-quick-reference',
26+
subtitle: 'Native Canaries · Shared by Test Sim',
27+
})
28+
)
29+
}, 30_000)
30+
31+
it('renders without a subtitle', async () => {
32+
await expectPng(await createCoverOgImage({ title: 'Protected file' }))
33+
}, 30_000)
34+
35+
it('lays out a title with no break opportunity', async () => {
36+
await expectPng(
37+
await createCoverOgImage({ title: 'a'.repeat(300), subtitle: 'Shared via Sim' })
38+
)
39+
}, 30_000)
40+
})

apps/sim/lib/og/cover-image.tsx

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
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+
}
94.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)