@@ -27,6 +27,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2727import path from 'node:path'
2828import type { CSSProperties } from 'react'
2929import { ImageResponse } from '@vercel/og'
30+ import matter from 'gray-matter'
3031import { parse as parseFont } from 'opentype.js'
3132import sharp from 'sharp'
3233
@@ -111,6 +112,50 @@ const TITLE_STYLE = {
111112/** Measures a string's rendered width in pixels at `fontSize`, in the cover typeface. */
112113type TextMeasurer = ( text : string , fontSize : number ) => number
113114
115+ /** Greedily packs `pieces` into chunks that each measure within `TITLE_BOX_WIDTH`. */
116+ function packChunks ( pieces : string [ ] , fontSize : number , measure : TextMeasurer ) : string [ ] {
117+ const chunks : string [ ] = [ ]
118+ let current = ''
119+
120+ for ( const piece of pieces ) {
121+ const candidate = current + piece
122+ if ( measure ( candidate , fontSize ) > TITLE_BOX_WIDTH && current ) {
123+ chunks . push ( current )
124+ current = piece
125+ } else {
126+ current = candidate
127+ }
128+ }
129+ if ( current ) chunks . push ( current )
130+
131+ return chunks
132+ }
133+
134+ /**
135+ * Breaks a single token that is wider than the title box on its own.
136+ *
137+ * `wrapTitleLines` can only break between space-separated words, so a long
138+ * hyphenated compound ("Bring-Your-Own-Key-Management") would otherwise sit on
139+ * a line that overflows the canvas — and since the rendered lines join with
140+ * non-breaking spaces, Satori's only remaining break opportunity is a hyphen,
141+ * putting the break somewhere nobody chose. Splitting here keeps that decision
142+ * in this file.
143+ *
144+ * Hyphens are tried first because that is where a reader (and a browser)
145+ * expects a compound to break; the trailing hyphen stays on the upper line.
146+ * A chunk with no usable hyphen falls back to a character-level split, which
147+ * only a pathological token (a long URL, an unbroken identifier) ever reaches.
148+ */
149+ function splitOversizedWord ( word : string , fontSize : number , measure : TextMeasurer ) : string [ ] {
150+ const afterHyphens = packChunks ( word . split ( / (?< = - ) / ) , fontSize , measure )
151+
152+ return afterHyphens . flatMap ( ( chunk ) =>
153+ measure ( chunk , fontSize ) <= TITLE_BOX_WIDTH
154+ ? [ chunk ]
155+ : packChunks ( [ ...chunk ] , fontSize , measure )
156+ )
157+ }
158+
114159/**
115160 * Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`,
116161 * then joins each line with U+00A0 instead of a plain space. Satori has a
@@ -131,6 +176,17 @@ function wrapTitleLines(title: string, fontSize: number, measure: TextMeasurer):
131176 let current = ''
132177
133178 for ( const word of title . split ( ' ' ) ) {
179+ if ( measure ( word , fontSize ) > TITLE_BOX_WIDTH ) {
180+ if ( current ) {
181+ lines . push ( current )
182+ current = ''
183+ }
184+ const chunks = splitOversizedWord ( word , fontSize , measure )
185+ lines . push ( ...chunks . slice ( 0 , - 1 ) )
186+ current = chunks [ chunks . length - 1 ] ?? ''
187+ continue
188+ }
189+
134190 const candidate = current ? `${ current } ${ word } ` : word
135191 if ( measure ( candidate , fontSize ) > TITLE_BOX_WIDTH && current ) {
136192 lines . push ( current )
@@ -144,16 +200,30 @@ function wrapTitleLines(title: string, fontSize: number, measure: TextMeasurer):
144200 return lines . map ( ( line ) => line . replace ( / / g, ' ' ) )
145201}
146202
147- /** Largest step whose title fits `MAX_TITLE_LINES`, falling back to the smallest step. */
203+ /**
204+ * Largest step whose title fits `MAX_TITLE_LINES` *and* whose every line fits
205+ * `TITLE_BOX_WIDTH`, falling back to the smallest step.
206+ *
207+ * Both conditions matter. `wrapTitleLines` cannot break inside a token, so a
208+ * single long word (a hyphenated compound like "Bring-Your-Own-Key", a URL)
209+ * can exceed the box on its own and still produce few enough lines to pass a
210+ * line-count-only test. That line would then overflow, and because the joined
211+ * spaces are non-breaking, Satori's only recourse is to break it at a hyphen —
212+ * reintroducing the mid-compound break this layout exists to avoid. Checking
213+ * measured width catches it and steps the size down instead.
214+ */
148215function layoutTitle ( title : string , measure : TextMeasurer ) : { fontSize : number ; lines : string [ ] } {
149216 let layout : { fontSize : number ; lines : string [ ] } = {
150217 fontSize : TITLE_FONT_SIZES [ 0 ] ,
151218 lines : [ title ] ,
152219 }
153220
154221 for ( const fontSize of TITLE_FONT_SIZES ) {
155- layout = { fontSize, lines : wrapTitleLines ( title , fontSize , measure ) }
156- if ( layout . lines . length <= MAX_TITLE_LINES ) break
222+ const lines = wrapTitleLines ( title , fontSize , measure )
223+ layout = { fontSize, lines }
224+
225+ const fitsBox = lines . every ( ( line ) => measure ( line , fontSize ) <= TITLE_BOX_WIDTH )
226+ if ( lines . length <= MAX_TITLE_LINES && fitsBox ) break
157227 }
158228
159229 return layout
@@ -251,19 +321,17 @@ async function countRedrawnPixels(committedPath: string, rendered: Buffer): Prom
251321
252322/**
253323 * Reads the `title` out of an MDX file's YAML frontmatter without pulling in a
254- * YAML parser — the field is a single quoted or bare scalar on one line.
324+ * YAML parser.
325+ *
326+ * Uses `gray-matter` rather than a regex because the page and its `og:title`
327+ * are parsed with `gray-matter` too (`lib/content/registry-factory.ts`). A
328+ * separate parser here could disagree with it on double-quoted escapes or
329+ * block scalars, and the cover would then confidently render a title the page
330+ * never shows — with `--check` calling it in sync.
255331 */
256332function readFrontmatterTitle ( source : string ) : string | null {
257- const frontmatter = source . match ( / ^ - - - \r ? \n ( [ \s \S ] * ?) \r ? \n - - - / )
258- if ( ! frontmatter ) return null
259-
260- const title = frontmatter [ 1 ] . match ( / ^ t i t l e : \s * ( .+ ?) \s * $ / m)
261- if ( ! title ) return null
262-
263- const raw = title [ 1 ]
264- const quoted = raw . match ( / ^ ( [ ' " ] ) ( [ \s \S ] * ) \1$ / )
265- if ( ! quoted ) return raw
266- return quoted [ 1 ] === "'" ? quoted [ 2 ] . replace ( / ' ' / g, "'" ) : quoted [ 2 ] . replace ( / \\ ( .) / g, '$1' )
333+ const { title } = matter ( source ) . data
334+ return typeof title === 'string' && title . length > 0 ? title : null
267335}
268336
269337async function main ( ) {
0 commit comments