@@ -5,25 +5,28 @@ import type { NextRequest } from 'next/server'
55export const runtime = 'edge'
66
77const TITLE_FONT_SIZE = {
8- large : 105 ,
9- medium : 91 ,
10- small : 81 ,
8+ large : 110 ,
9+ medium : 96 ,
10+ small : 85 ,
1111} as const
1212/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
13- const AVG_CHAR_WIDTH_EM = 0.46
13+ const LATIN_CHAR_WIDTH_EM = 0.42
14+ /** CJK glyphs (docs ships `ja`/`zh` locales) render near-square, roughly 2.4x a Latin glyph at this weight. */
15+ const CJK_CHAR_WIDTH_EM = 1
16+ const CJK_RANGE = / [ \u3000 - \u30ff \u3400 - \u4dbf \u4e00 - \u9fff \uf900 - \ufaff \uff00 - \uffef ] /
1417const TITLE_BOX_WIDTH = 1020
1518const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30
16- /** Measured off the reference cover template (`apps/sim/public/library/best-zapier-alternatives/cover.jpg`) . */
17- const INK_COLOR = '#525252 '
19+ /** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels . */
20+ const INK_COLOR = '#515151 '
1821const OG_CONTAINER_STYLE = {
1922 height : '100%' ,
2023 width : '100%' ,
2124 display : 'flex' ,
2225 flexDirection : 'column' ,
2326 justifyContent : 'space-between' ,
2427 padding : '26px' ,
25- background : '#c3c3c3 ' ,
26- fontFamily : 'Season ' ,
28+ background : '#c1c1c1 ' ,
29+ fontFamily : 'Soehne ' ,
2730} satisfies CSSProperties
2831const OG_HEADER_STYLE = {
2932 display : 'flex' ,
@@ -34,10 +37,12 @@ const OG_HEADER_STYLE = {
3437const OG_TITLE_STYLE = {
3538 display : 'flex' ,
3639 flexDirection : 'column' ,
37- fontWeight : 600 ,
40+ fontWeight : 500 ,
3841 color : INK_COLOR ,
39- lineHeight : 1.15 ,
42+ lineHeight : 1.1 ,
4043 width : `${ TITLE_BOX_WIDTH } px` ,
44+ /** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */
45+ transform : 'translateY(14px)' ,
4146} satisfies CSSProperties
4247
4348function getTitleFontSize ( title : string ) : number {
@@ -53,6 +58,41 @@ function getTitleStyle(title: string): CSSProperties {
5358 }
5459}
5560
61+ /** Sums per-character em-widths rather than counting characters, so wide CJK glyphs (docs ships `ja`/`zh`) don't under-wrap. */
62+ function estimateWidthEm ( text : string ) : number {
63+ let width = 0
64+ for ( const char of text ) {
65+ width += CJK_RANGE . test ( char ) ? CJK_CHAR_WIDTH_EM : LATIN_CHAR_WIDTH_EM
66+ }
67+ return width
68+ }
69+
70+ /**
71+ * Splits a single word wider than `maxWidthEm` into character-level chunks
72+ * that each fit. CJK titles (docs ships `ja`/`zh` locales) are often
73+ * space-free, so a whole run can arrive as one "word" from `wrapTitleLines`'
74+ * space-based split. Breaking mid-word is correct for CJK, where each glyph
75+ * is independently readable; Latin words never reach this path since they
76+ * stay under `maxWidthEm` in practice.
77+ */
78+ function splitOversizedWord ( word : string , maxWidthEm : number ) : string [ ] {
79+ const chunks : string [ ] = [ ]
80+ let chunk = ''
81+
82+ for ( const char of word ) {
83+ const candidate = chunk + char
84+ if ( estimateWidthEm ( candidate ) > maxWidthEm && chunk ) {
85+ chunks . push ( chunk )
86+ chunk = char
87+ } else {
88+ chunk = candidate
89+ }
90+ }
91+ if ( chunk ) chunks . push ( chunk )
92+
93+ return chunks
94+ }
95+
5696/**
5797 * Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`,
5898 * then joins each line with U+00A0 instead of a plain space. Satori
@@ -63,14 +103,25 @@ function getTitleStyle(title: string): CSSProperties {
63103 * (which is also disabled here — lines are pre-split, not auto-wrapped).
64104 */
65105function wrapTitleLines ( title : string , fontSize : number ) : string [ ] {
66- const maxCharsPerLine = Math . floor ( TITLE_BOX_WIDTH / ( fontSize * AVG_CHAR_WIDTH_EM ) )
106+ const maxWidthEm = TITLE_BOX_WIDTH / fontSize
67107 const words = title . split ( ' ' )
68108 const lines : string [ ] = [ ]
69109 let current = ''
70110
71111 for ( const word of words ) {
112+ if ( estimateWidthEm ( word ) > maxWidthEm ) {
113+ if ( current ) {
114+ lines . push ( current )
115+ current = ''
116+ }
117+ const chunks = splitOversizedWord ( word , maxWidthEm )
118+ lines . push ( ...chunks . slice ( 0 , - 1 ) )
119+ current = chunks [ chunks . length - 1 ] ?? ''
120+ continue
121+ }
122+
72123 const candidate = current ? `${ current } ${ word } ` : word
73- if ( candidate . length > maxCharsPerLine && current ) {
124+ if ( estimateWidthEm ( candidate ) > maxWidthEm && current ) {
74125 lines . push ( current )
75126 current = word
76127 } else {
@@ -83,21 +134,16 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
83134}
84135
85136/**
86- * Loads a static (600/semibold) TTF instance of the site's own Season Sans
87- * font — the platform's real brand/body font, also used by the library/blog
88- * cover template this OG image matches. Instantiated from the variable font
89- * at `apps/docs/app/fonts/SeasonSansUprightsVF.woff2` (`fonttools
90- * varLib.instancer wght=600`, then flavor-stripped to plain TTF) rather than
91- * loading the variable WOFF2 directly: Satori (`next/og`'s renderer) can't
92- * parse variable fonts without excessive memory use, and can't parse WOFF2
93- * at all ("Unsupported OpenType signature wOF2") — it needs an uncompressed
94- * TTF/OTF. Fetched over HTTP since the edge runtime has no filesystem access
95- * — served from `/static/fonts/` (not `/fonts/`) so it isn't intercepted by
96- * the site's i18n proxy (`proxy.ts`), whose matcher excludes `static` but
97- * not `fonts`.
137+ * Loads Söhne Kräftig (weight 500), the typeface used on the reference cover
138+ * template this OG image matches. Converted to a plain TTF from the
139+ * last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer)
140+ * can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge
141+ * runtime has no filesystem access — served from `/static/fonts/` (not
142+ * `/fonts/`) so it isn't intercepted by the site's i18n proxy (`proxy.ts`),
143+ * whose matcher excludes `static` but not `fonts`.
98144 */
99- async function loadSeasonFont ( baseUrl : string ) : Promise < ArrayBuffer > {
100- const response = await fetch ( new URL ( '/static/fonts/SeasonSans-600-static .ttf' , baseUrl ) , {
145+ async function loadTitleFont ( baseUrl : string ) : Promise < ArrayBuffer > {
146+ const response = await fetch ( new URL ( '/static/fonts/Soehne-Kraftig .ttf' , baseUrl ) , {
101147 next : { revalidate : FONT_CACHE_REVALIDATE_SECONDS } ,
102148 } )
103149
@@ -128,16 +174,16 @@ function SimWordmark() {
128174 )
129175}
130176
131- /** Diagonal "open" arrow, top-right — matches the library/blog cover template . */
177+ /** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners . */
132178function CornerArrow ( ) {
133179 return (
134180 < svg width = '58' height = '58' viewBox = '0 0 24 24' fill = 'none' >
135181 < path
136182 d = 'M2 22 22 2M22 2H12M22 2V12'
137183 stroke = { INK_COLOR }
138184 strokeWidth = { 3.6 }
139- strokeLinecap = 'round '
140- strokeLinejoin = 'round '
185+ strokeLinecap = 'square '
186+ strokeLinejoin = 'miter '
141187 />
142188 </ svg >
143189 )
@@ -153,7 +199,7 @@ export async function GET(request: NextRequest) {
153199 const { searchParams } = new URL ( request . url )
154200 const title = searchParams . get ( 'title' ) || 'Documentation'
155201
156- const fontData = await loadSeasonFont ( request . url )
202+ const fontData = await loadTitleFont ( request . url )
157203 const fontSize = getTitleFontSize ( title )
158204 const titleLines = wrapTitleLines ( title , fontSize )
159205
@@ -175,10 +221,10 @@ export async function GET(request: NextRequest) {
175221 height : 675 ,
176222 fonts : [
177223 {
178- name : 'Season ' ,
224+ name : 'Soehne ' ,
179225 data : fontData ,
180226 style : 'normal' ,
181- weight : 600 ,
227+ weight : 500 ,
182228 } ,
183229 ] ,
184230 }
0 commit comments