fix(og): put the landing model and integration cards on the brandbook template (#6913)

* fix(og): put the landing model and integration cards on the brandbook template

The five models/integrations cards were the last ones still rendering the
retired dark card with the green square-icon logo. They now share the cover
renderer the library, docs, and shared-file cards use, which retires
og-utils and the assets only it referenced.

Captions wrap to two lines so a catalog description survives the move; the
eyebrow, pill row, and domain label have no slot in the reference template
and are dropped, with the counts they carried folded into the two index
captions instead.

* fix(og): drop the dead hard-space helper and restore the unknown-context copy

withHardSpaces lost its last caller when captions moved onto wrapLines, which
packs the non-breaking space inline; its rationale moves to wrapLines with it.

A missing context window rendered as a bare "context window", reading as a
field label mid-sentence. formatTokenCount already returns "Unknown" for a
null value, so the ternary was both redundant and wrong.
This commit is contained in:
Waleed
2026-08-20 17:34:58 -07:00
committed by GitHub
parent 040f40cd61
commit e4a1fbeaae
15 changed files with 119 additions and 474 deletions
@@ -1 +0,0 @@
export { SimLogoFull } from './og-sim-logo'
@@ -1,21 +0,0 @@
/** The full Sim wordmark, rendered as inline SVG for use inside satori OG images. */
export function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
fill='#fafafa'
/>
</svg>
)
}
@@ -1,24 +1,15 @@
import integrationsJson from '@sim/deployment-config/integrations.json'
import { notFound } from 'next/navigation'
import type { AuthType, Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
import type { Integration } from '@/lib/integrations/types'
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export const size = COVER_OG_SIZE
/** Raw catalog JSON, not the barrel - keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const bySlug = new Map(integrations.map((i) => [i.slug, i]))
const AUTH_LABEL: Record<AuthType, string> = {
oauth: 'One-click OAuth',
'api-key': 'API key auth',
none: 'No auth required',
}
/**
* The sibling page.tsx sets `dynamicParams = false`, a segment-level
* restriction that also blocks this metadata route from rendering any
@@ -38,22 +29,8 @@ export default async function Image({ params }: { params: Promise<{ slug: string
notFound()
}
const pills = [
integration.operationCount > 0
? `${integration.operationCount} tool${integration.operationCount === 1 ? '' : 's'}`
: null,
integration.triggerCount > 0
? `${integration.triggerCount} real-time trigger${integration.triggerCount === 1 ? '' : 's'}`
: null,
AUTH_LABEL[integration.authType],
'Free to start',
].filter((pill): pill is string => pill !== null)
return createLandingOgImage({
eyebrow: 'Sim integration',
return createCoverOgImage({
title: `${integration.name} Integration`,
subtitle: integration.description,
pills,
domainLabel: `sim.ai/integrations/${slug}`,
})
}
@@ -1,30 +1,17 @@
import integrationsJson from '@sim/deployment-config/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export const size = COVER_OG_SIZE
/** Raw catalog JSON, not the barrel - keeps `@/blocks/registry` out of the OG bundle. */
const integrations = integrationsJson.integrations as readonly Integration[]
const TOTAL_TOOL_COUNT = integrations.reduce((sum, i) => sum + i.operationCount, 0)
const OAUTH_COUNT = integrations.filter((i) => i.authType === 'oauth').length
const TRIGGER_INTEGRATION_COUNT = integrations.filter((i) => i.triggerCount > 0).length
export default async function Image() {
return createLandingOgImage({
eyebrow: 'Sim integrations directory',
return createCoverOgImage({
title: 'Integrations',
subtitle: `Connect ${integrations.length} apps and services to AI agents in Sim's workflow builder, visually, conversationally, or with code.`,
pills: [
`${integrations.length} integrations`,
`${TOTAL_TOOL_COUNT}+ tools`,
`${OAUTH_COUNT} OAuth apps`,
`${TRIGGER_INTEGRATION_COUNT} with real-time triggers`,
],
domainLabel: 'sim.ai/integrations',
subtitle: `Connect ${integrations.length} apps and services and ${TOTAL_TOOL_COUNT}+ tools to AI agents in Sim — visually, conversationally, or with code.`,
})
}
@@ -1,4 +1,5 @@
import { notFound } from 'next/navigation'
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
import {
ALL_CATALOG_MODELS,
formatPrice,
@@ -6,13 +7,9 @@ import {
getModelBySlug,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export const size = COVER_OG_SIZE
/**
* The sibling page.tsx sets `dynamicParams = false`, a segment-level
@@ -41,16 +38,8 @@ export default async function Image({
notFound()
}
return createLandingOgImage({
eyebrow: `${provider.name} model`,
return createCoverOgImage({
title: model.displayName,
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
pills: [
`Input ${formatPrice(model.pricing.input)}/1M`,
`Output ${formatPrice(model.pricing.output)}/1M`,
model.contextWindow ? `${formatTokenCount(model.contextWindow)} context` : 'Unknown context',
model.capabilityTags[0] ?? 'Capabilities tracked',
],
domainLabel: `sim.ai${model.href}`,
subtitle: `${provider.name} · ${formatPrice(model.pricing.input)}/1M in, ${formatPrice(model.pricing.output)}/1M out, ${formatTokenCount(model.contextWindow)} context — from Sim's model registry.`,
})
}
@@ -1,19 +1,9 @@
import { notFound } from 'next/navigation'
import {
formatPrice,
formatTokenCount,
getCheapestProviderModel,
getLargestContextProviderModel,
getProviderBySlug,
MODEL_PROVIDERS_WITH_CATALOGS,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
import { getProviderBySlug, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export const size = COVER_OG_SIZE
/**
* The sibling page.tsx sets `dynamicParams = false`, a segment-level
@@ -36,21 +26,8 @@ export default async function Image({ params }: { params: Promise<{ provider: st
notFound()
}
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return createLandingOgImage({
eyebrow: `${provider.name} on Sim`,
return createCoverOgImage({
title: `${provider.name} models`,
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
pills: [
`${provider.modelCount} tracked`,
provider.defaultModelDisplayName || 'Dynamic default',
cheapestModel ? `From ${formatPrice(cheapestModel.pricing.input)}/1M` : 'Pricing tracked',
largestContextModel?.contextWindow
? `${formatTokenCount(largestContextModel.contextWindow)} context`
: 'Context tracked',
],
domainLabel: `sim.ai${provider.href}`,
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and capability coverage.`,
})
}
@@ -1,28 +1,12 @@
import {
formatTokenCount,
MAX_CONTEXT_WINDOW,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
import { TOTAL_MODEL_PROVIDERS, TOTAL_MODELS } from '@/app/(landing)/models/utils'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export const size = COVER_OG_SIZE
export default async function Image() {
return createLandingOgImage({
eyebrow: 'Sim model directory',
return createCoverOgImage({
title: 'AI Models Directory',
subtitle:
'Browse tracked AI models with pricing, context windows, and workflow-ready capability details.',
pills: [
`${TOTAL_MODELS} models`,
`${TOTAL_MODEL_PROVIDERS} providers`,
`${formatTokenCount(MAX_CONTEXT_WINDOW)} max context`,
],
domainLabel: 'sim.ai/models',
subtitle: `Browse ${TOTAL_MODELS} models from ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and workflow-ready capability details.`,
})
}
-3
View File
@@ -589,9 +589,6 @@ export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
)
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
export const MAX_CONTEXT_WINDOW = Math.max(
...ALL_CATALOG_MODELS.map((model) => model.contextWindow ?? 0)
)
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
(provider) => provider.name
)
-28
View File
@@ -1,28 +0,0 @@
/**
* @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)
})
-173
View File
@@ -1,173 +0,0 @@
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'
const size = {
width: 1200,
height: 630,
}
const TITLE_FONT_SIZE = {
large: 64,
medium: 56,
small: 48,
} as const
function getTitleFontSize(title: string): number {
if (title.length > 42) return TITLE_FONT_SIZE.small
if (title.length > 26) return TITLE_FONT_SIZE.medium
return TITLE_FONT_SIZE.large
}
/**
* 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')
const [geistRegular, geistMedium] = await Promise.all([
readFile(join(FONT_DIR, 'Geist-Regular.ttf')),
readFile(join(FONT_DIR, 'Geist-Medium.ttf')),
])
interface LandingOgImageProps {
eyebrow: string
title: string
subtitle: string
pills?: string[]
domainLabel?: string
}
/** Shared dynamic OG image for landing catalog pages (models, integrations). */
export async function createLandingOgImage({
eyebrow,
title,
subtitle,
pills = [],
domainLabel = 'sim.ai',
}: LandingOgImageProps) {
return new ImageResponse(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '56px 64px',
background: '#121212',
fontFamily: 'Geist',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<span
style={{
fontSize: 22,
fontWeight: 500,
color: '#71717a',
letterSpacing: '-0.01em',
}}
>
{eyebrow}
</span>
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 500,
color: '#fafafa',
lineHeight: 1.08,
letterSpacing: '-0.03em',
maxWidth: '1000px',
}}
>
{title}
</span>
<span
style={{
fontSize: 28,
fontWeight: 400,
color: '#a1a1aa',
lineHeight: 1.35,
maxWidth: '980px',
}}
>
{subtitle}
</span>
{pills.length > 0 ? (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 4 }}>
{pills.slice(0, 4).map((pill) => (
<div
key={pill}
style={{
display: 'flex',
alignItems: 'center',
borderRadius: 9999,
border: '1px solid #2f2f2f',
background: '#1b1b1b',
padding: '10px 16px',
color: '#d4d4d8',
fontSize: 20,
fontWeight: 500,
}}
>
{pill}
</div>
))}
</div>
) : null}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
<span
style={{
fontSize: 20,
fontWeight: 400,
color: '#71717a',
}}
>
{domainLabel}
</span>
</div>
</div>,
{
...size,
fonts: [
{ name: 'Geist', data: geistRegular, style: 'normal' as const, weight: 400 as const },
{ name: 'Geist', data: geistMedium, style: 'normal' as const, weight: 500 as const },
],
}
)
}
+22 -10
View File
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { parse as parseFont } from 'opentype.js'
import { describe, expect, it } from 'vitest'
import {
COVER_MAX_CAPTION_LINES,
COVER_MAX_TITLE_LINES,
COVER_TITLE_BOX_WIDTH,
createCoverOgImage,
@@ -47,10 +48,11 @@ describe('cover OG layout', () => {
expect(measure(line, layout.fontSize)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
}
if (subtitle) {
expect(layout.subtitle).not.toBeNull()
expect(measure(layout.subtitle as string, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(
COVER_TITLE_BOX_WIDTH
)
expect(layout.subtitleLines.length).toBeGreaterThan(0)
expect(layout.subtitleLines.length).toBeLessThanOrEqual(COVER_MAX_CAPTION_LINES)
for (const line of layout.subtitleLines) {
expect(measure(line, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
}
}
return layout
}
@@ -59,7 +61,7 @@ describe('cover OG layout', () => {
const layout = expectWithinCanvas('Protected file')
expect(layout.lines.map(plain)).toEqual(['Protected file'])
expect(layout.fontSize).toBe(110)
expect(layout.subtitle).toBeNull()
expect(layout.subtitleLines).toEqual([])
})
it('breaks a hyphenated file name after a hyphen', () => {
@@ -95,17 +97,27 @@ describe('cover OG layout', () => {
expect(layout.lines[COVER_MAX_TITLE_LINES - 1].endsWith('…')).toBe(true)
})
it('truncates a caption too long for one line', () => {
it('wraps a catalog-length caption onto a second line', () => {
const layout = expectWithinCanvas(
'Integrations',
'Connect 240 apps and services and 3800+ tools to AI agents in Sim — visually, conversationally, or with code.'
)
expect(layout.subtitleLines).toHaveLength(2)
expect(layout.subtitleLines.join('')).not.toContain('…')
})
it('truncates a caption too long even for two lines', () => {
const layout = expectWithinCanvas(
'report.pdf',
`${'Very Long Workspace Name '.repeat(10)}· Shared by Someone`
)
expect((layout.subtitle as string).endsWith('…')).toBe(true)
expect(layout.subtitleLines).toHaveLength(COVER_MAX_CAPTION_LINES)
expect(layout.subtitleLines[COVER_MAX_CAPTION_LINES - 1].endsWith('…')).toBe(true)
})
it('leaves a caption that already fits intact', () => {
it('leaves a caption that already fits on one line intact', () => {
const layout = expectWithinCanvas('report.pdf', 'Design · Shared by Someone')
expect(plain(layout.subtitle as string)).toBe('Design · Shared by Someone')
expect(layout.subtitleLines.map(plain)).toEqual(['Design · Shared by Someone'])
})
/**
@@ -118,7 +130,7 @@ describe('cover OG layout', () => {
const layout = expectWithinCanvas('two words.pdf', 'Design · Shared by Someone')
expect(layout.lines[0]).toContain('\u00a0')
expect(layout.lines.join('')).not.toContain(' ')
expect(layout.subtitle).not.toContain(' ')
expect(layout.subtitleLines.join('')).not.toContain(' ')
})
})
+75 -37
View File
@@ -27,6 +27,13 @@ const MUTED_INK_COLOR = 'rgba(81, 81, 81, 0.72)'
/** Tried largest-first; the first size whose title wraps within `COVER_MAX_TITLE_LINES` wins. */
const TITLE_FONT_SIZES = [110, 96, 85] as const
const SUBTITLE_FONT_SIZE = 30
/**
* How far the title is nudged down to swallow the invisible leading Satori
* adds below its last line instead of splitting it evenly.
*/
const TITLE_LEADING_NUDGE = 14
/** Intended optical gap between the title's baseline row and the caption. */
const CAPTION_GAP = 18
const ELLIPSIS = '\u2026'
/** Width the title and its caption are laid out into, leaving the right third of the card open. */
export const COVER_TITLE_BOX_WIDTH = 1020
@@ -37,6 +44,13 @@ export const COVER_TITLE_BOX_WIDTH = 1020
* type down and then truncates rather than growing.
*/
export const COVER_MAX_TITLE_LINES = 3
/**
* Captions run from one-line provenance up to a full catalog description, so
* they wrap but two lines is the ceiling. A third turns the block into a
* paragraph competing with the title for the eye, which is the balance the
* reference template is built around.
*/
export const COVER_MAX_CAPTION_LINES = 2
/**
* Söhne Kräftig (weight 500), the typeface of the reference cover template, as
@@ -49,8 +63,15 @@ export const COVER_MAX_TITLE_LINES = 3
* per-request cards need since they render outside the build. `process.cwd()`
* is the app directory in every environment this runs in Next's generated
* standalone `server.js` opens with `process.chdir(__dirname)`, and that file
* ships beside `public/`. See `app/(landing)/og-utils.tsx` for the longer
* account of why these are not fetched.
* ships beside `public/`.
*
* Read from the repo rather than fetched, because a fetch that returns nothing
* throws "No fonts are loaded" out of Satori and takes the whole build down on
* whichever page happens to be rendering. That was not hypothetical: the
* retired landing card fetched two Google Fonts weights subsetted per page via
* `&text=`, a URL no cache can reuse, across `integrations/[slug]` alone at 237
* pages several hundred uncacheable requests to one host from one CI egress
* IP, in parallel across build workers.
*/
const titleFont = await readFile(
join(process.cwd(), 'public', 'brand', 'fonts', 'Soehne-Kraftig.ttf')
@@ -108,14 +129,22 @@ const TITLE_STYLE = {
/**
* Compensates for Satori adding extra invisible leading below the last line
* instead of splitting it evenly. It belongs on the title block, not on the
* footer: on the footer it would drag the caption 14px toward the bottom
* padding too, and the caption has no such leading to correct for.
* footer: on the footer it would drag the caption toward the bottom padding
* too, and the caption has no such leading to correct for.
*/
transform: 'translateY(14px)',
transform: `translateY(${TITLE_LEADING_NUDGE}px)`,
} satisfies CSSProperties
const SUBTITLE_STYLE = {
display: 'flex',
marginTop: 18,
flexDirection: 'column',
/**
* The nudge is a transform, so it moves the title visually without moving
* its layout box the caption stays put while the title descends into the
* gap. Adding it back here restores `CAPTION_GAP` as the gap actually seen.
* The footer is bottom-anchored, so this lifts the title rather than pushing
* the caption into the bottom padding.
*/
marginTop: CAPTION_GAP + TITLE_LEADING_NUDGE,
fontSize: SUBTITLE_FONT_SIZE,
fontWeight: 500,
color: MUTED_INK_COLOR,
@@ -172,24 +201,21 @@ function splitOversizedWord(word: string, fontSize: number): string[] {
}
/**
* Replaces every plain space (U+0020) with U+00A0. Satori has a
* text-measurement bug where the first plain space in a text node renders at
* roughly double width a non-breaking space measures correctly and reads
* identically at these sizes, so it sidesteps the bug rather than fighting
* Satori's own line-wrapping, which is disabled here since lines arrive
* pre-split. Title lines are packed with the U+00A0 already in them so that
* what is measured is exactly what is rendered.
* Greedily packs words into lines that fit `COVER_TITLE_BOX_WIDTH` at
* `fontSize`, joining them with U+00A0 rather than a plain space.
*
* Satori has a text-measurement bug where the first plain space (U+0020) in a
* text node renders at roughly double width; a non-breaking space measures
* correctly and reads identically at these sizes, so this sidesteps the bug
* rather than fighting Satori's own line-wrapping, which is disabled here
* since lines arrive pre-split. The U+00A0 goes in as the line is packed, so
* what gets measured is exactly what gets rendered.
*/
function withHardSpaces(text: string): string {
return text.replace(/ /g, '\u00a0')
}
/** Greedily packs words into lines that fit `COVER_TITLE_BOX_WIDTH` at `fontSize`. */
function wrapTitleLines(title: string, fontSize: number): string[] {
function wrapLines(text: string, fontSize: number): string[] {
const lines: string[] = []
let current = ''
for (const word of title.split(' ')) {
for (const word of text.split(' ')) {
if (!fits(word, fontSize)) {
if (current) {
lines.push(current)
@@ -253,23 +279,25 @@ export const COVER_OG_SIZE = { width: COVER_WIDTH, height: COVER_HEIGHT } as con
interface CoverOgImageProps {
title: string
/** Optional caption under the title — provenance, a byline, a section label. */
/** Optional caption under the title — provenance, a description, a byline. */
subtitle?: string
}
interface CoverLayout {
fontSize: number
lines: string[]
subtitle: string | null
subtitleLines: string[]
}
/**
* Largest type size at which the title fits `COVER_MAX_TITLE_LINES`, with the
* overflow truncated at the smallest step, and a caption clipped to one line.
* overflow truncated at the smallest step, and the caption wrapped to at most
* `COVER_MAX_CAPTION_LINES`.
*
* Separate from the render so the bound is assertable: every string this
* returns has to sit inside the fixed canvas, and both inputs a file name
* and a workspace/owner pair are supplied by whoever created the share.
* returns has to sit inside the fixed canvas, and on the shared-file card both
* inputs a file name and a workspace/owner pair are supplied by whoever
* created the share.
*/
export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout {
const smallest = TITLE_FONT_SIZES[TITLE_FONT_SIZES.length - 1]
@@ -278,7 +306,7 @@ export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout
for (const step of TITLE_FONT_SIZES) {
fontSize = step
lines = wrapTitleLines(title, step)
lines = wrapLines(title, step)
if (lines.length <= COVER_MAX_TITLE_LINES) break
}
@@ -287,24 +315,28 @@ export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout
lines[lines.length - 1] = withEllipsis(lines[lines.length - 1], smallest)
}
return { fontSize, lines, subtitle: subtitle ? fitCaption(subtitle) : null }
return { fontSize, lines, subtitleLines: subtitle ? layoutCaption(subtitle) : [] }
}
/**
* Clips a caption to a single line. Because `withHardSpaces` leaves Satori no
* break opportunities, an over-long caption would otherwise run straight off
* the right edge instead of wrapping.
* Wraps a caption to at most `COVER_MAX_CAPTION_LINES`, ellipsizing the last
* line if it still overruns. The lines have to be pre-split here for the same
* reason the title's are: packing them with U+00A0 leaves Satori no break
* opportunity, so an unsplit caption would run straight off the right edge
* rather than wrapping.
*/
function fitCaption(subtitle: string): string {
const fitted = fits(subtitle, SUBTITLE_FONT_SIZE)
? subtitle
: withEllipsis(subtitle, SUBTITLE_FONT_SIZE)
return withHardSpaces(fitted)
function layoutCaption(subtitle: string): string[] {
const lines = wrapLines(subtitle, SUBTITLE_FONT_SIZE)
if (lines.length <= COVER_MAX_CAPTION_LINES) return lines
const kept = lines.slice(0, COVER_MAX_CAPTION_LINES)
kept[kept.length - 1] = withEllipsis(kept[kept.length - 1], SUBTITLE_FONT_SIZE)
return kept
}
/** Renders the brandbook cover template for a single title. */
export function createCoverOgImage(props: CoverOgImageProps) {
const { fontSize, lines, subtitle } = layoutCover(props)
const { fontSize, lines, subtitleLines } = layoutCover(props)
return new ImageResponse(
<div style={CONTAINER_STYLE}>
@@ -319,7 +351,13 @@ export function createCoverOgImage(props: CoverOgImageProps) {
<span key={index}>{line}</span>
))}
</div>
{subtitle ? <span style={SUBTITLE_STYLE}>{subtitle}</span> : null}
{subtitleLines.length > 0 ? (
<div style={SUBTITLE_STYLE}>
{subtitleLines.map((line, index) => (
<span key={index}>{line}</span>
))}
</div>
) : null}
</div>
</div>,
{
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.