Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 78 additions & 32 deletions apps/docs/app/api/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,28 @@ import type { NextRequest } from 'next/server'
export const runtime = 'edge'

const TITLE_FONT_SIZE = {
large: 105,
medium: 91,
small: 81,
large: 110,
medium: 96,
small: 85,
} as const
/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
const AVG_CHAR_WIDTH_EM = 0.46
const LATIN_CHAR_WIDTH_EM = 0.42
/** CJK glyphs (docs ships `ja`/`zh` locales) render near-square, roughly 2.4x a Latin glyph at this weight. */
const CJK_CHAR_WIDTH_EM = 1
const CJK_RANGE = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/
const TITLE_BOX_WIDTH = 1020
const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30
/** Measured off the reference cover template (`apps/sim/public/library/best-zapier-alternatives/cover.jpg`). */
const INK_COLOR = '#525252'
/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */
const INK_COLOR = '#515151'
const OG_CONTAINER_STYLE = {
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '26px',
background: '#c3c3c3',
fontFamily: 'Season',
background: '#c1c1c1',
fontFamily: 'Soehne',
} satisfies CSSProperties
const OG_HEADER_STYLE = {
display: 'flex',
Expand All @@ -34,10 +37,12 @@ const OG_HEADER_STYLE = {
const OG_TITLE_STYLE = {
display: 'flex',
flexDirection: 'column',
fontWeight: 600,
fontWeight: 500,
color: INK_COLOR,
lineHeight: 1.15,
lineHeight: 1.1,
width: `${TITLE_BOX_WIDTH}px`,
/** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */
transform: 'translateY(14px)',
} satisfies CSSProperties

function getTitleFontSize(title: string): number {
Expand All @@ -53,6 +58,41 @@ function getTitleStyle(title: string): CSSProperties {
}
}

/** Sums per-character em-widths rather than counting characters, so wide CJK glyphs (docs ships `ja`/`zh`) don't under-wrap. */
function estimateWidthEm(text: string): number {
let width = 0
for (const char of text) {
width += CJK_RANGE.test(char) ? CJK_CHAR_WIDTH_EM : LATIN_CHAR_WIDTH_EM
}
return width
}

/**
* Splits a single word wider than `maxWidthEm` into character-level chunks
* that each fit. CJK titles (docs ships `ja`/`zh` locales) are often
* space-free, so a whole run can arrive as one "word" from `wrapTitleLines`'
* space-based split. Breaking mid-word is correct for CJK, where each glyph
* is independently readable; Latin words never reach this path since they
* stay under `maxWidthEm` in practice.
*/
function splitOversizedWord(word: string, maxWidthEm: number): string[] {
const chunks: string[] = []
let chunk = ''

for (const char of word) {
const candidate = chunk + char
if (estimateWidthEm(candidate) > maxWidthEm && chunk) {
chunks.push(chunk)
chunk = char
} else {
chunk = candidate
}
}
if (chunk) chunks.push(chunk)

return chunks
}

/**
* Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`,
* then joins each line with U+00A0 instead of a plain space. Satori
Expand All @@ -63,14 +103,25 @@ function getTitleStyle(title: string): CSSProperties {
* (which is also disabled here — lines are pre-split, not auto-wrapped).
*/
function wrapTitleLines(title: string, fontSize: number): string[] {
const maxCharsPerLine = Math.floor(TITLE_BOX_WIDTH / (fontSize * AVG_CHAR_WIDTH_EM))
const maxWidthEm = TITLE_BOX_WIDTH / fontSize
const words = title.split(' ')
const lines: string[] = []
let current = ''

for (const word of words) {
if (estimateWidthEm(word) > maxWidthEm) {
if (current) {
lines.push(current)
current = ''
}
const chunks = splitOversizedWord(word, maxWidthEm)
lines.push(...chunks.slice(0, -1))
current = chunks[chunks.length - 1] ?? ''
continue
}

const candidate = current ? `${current} ${word}` : word
if (candidate.length > maxCharsPerLine && current) {
if (estimateWidthEm(candidate) > maxWidthEm && current) {
Comment thread
waleedlatif1 marked this conversation as resolved.
lines.push(current)
current = word
} else {
Expand All @@ -83,21 +134,16 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
}

/**
* Loads a static (600/semibold) TTF instance of the site's own Season Sans
* font — the platform's real brand/body font, also used by the library/blog
* cover template this OG image matches. Instantiated from the variable font
* at `apps/docs/app/fonts/SeasonSansUprightsVF.woff2` (`fonttools
* varLib.instancer wght=600`, then flavor-stripped to plain TTF) rather than
* loading the variable WOFF2 directly: Satori (`next/og`'s renderer) can't
* parse variable fonts without excessive memory use, and can't parse WOFF2
* at all ("Unsupported OpenType signature wOF2") — it needs an uncompressed
* TTF/OTF. Fetched over HTTP since the edge runtime has no filesystem access
* — served from `/static/fonts/` (not `/fonts/`) so it isn't intercepted by
* the site's i18n proxy (`proxy.ts`), whose matcher excludes `static` but
* not `fonts`.
* Loads Söhne Kräftig (weight 500), the typeface used on the reference cover
* template this OG image matches. Converted to a plain TTF from the
* last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer)
* can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge
* runtime has no filesystem access — served from `/static/fonts/` (not
* `/fonts/`) so it isn't intercepted by the site's i18n proxy (`proxy.ts`),
* whose matcher excludes `static` but not `fonts`.
*/
async function loadSeasonFont(baseUrl: string): Promise<ArrayBuffer> {
const response = await fetch(new URL('/static/fonts/SeasonSans-600-static.ttf', baseUrl), {
async function loadTitleFont(baseUrl: string): Promise<ArrayBuffer> {
const response = await fetch(new URL('/static/fonts/Soehne-Kraftig.ttf', baseUrl), {
next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS },
})

Expand Down Expand Up @@ -128,16 +174,16 @@ function SimWordmark() {
)
}

/** Diagonal "open" arrow, top-right — matches the library/blog cover template. */
/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */
function CornerArrow() {
return (
<svg width='58' height='58' viewBox='0 0 24 24' fill='none'>
<path
d='M2 22 22 2M22 2H12M22 2V12'
stroke={INK_COLOR}
strokeWidth={3.6}
strokeLinecap='round'
strokeLinejoin='round'
strokeLinecap='square'
strokeLinejoin='miter'
/>
</svg>
)
Expand All @@ -153,7 +199,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'Documentation'

const fontData = await loadSeasonFont(request.url)
const fontData = await loadTitleFont(request.url)
const fontSize = getTitleFontSize(title)
const titleLines = wrapTitleLines(title, fontSize)

Expand All @@ -175,10 +221,10 @@ export async function GET(request: NextRequest) {
height: 675,
fonts: [
{
name: 'Season',
name: 'Soehne',
data: fontData,
style: 'normal',
weight: 600,
weight: 500,
},
],
}
Expand Down
Binary file not shown.
Binary file added apps/docs/public/static/fonts/Soehne-Kraftig.ttf
Binary file not shown.
Loading