Skip to content

Commit cbee086

Browse files
committed
fix(library): parse frontmatter with gray-matter and split oversized tokens
Two issues in the cover generator, neither reachable from a current title. The hand-rolled frontmatter regex could disagree with `gray-matter`, which is what renders the page and its `og:title`. On a double-quoted escape or a block scalar the cover would have rendered a title the page never shows, with `--check` calling it in sync. Uses `gray-matter` directly so there is one parser. `wrapTitleLines` only breaks between space-separated words, so a token wider than the title box on its own stayed on an overflowing line, and the non-breaking spaces left Satori no recourse but to break it at a hyphen — the mid-compound break this layout exists to prevent. Oversized tokens now split here, at hyphens first and per-character only for something like a URL, and a font size is accepted only if every line measures within the box. All 20 covers re-render byte-identically, so neither change alters current output.
1 parent 8be2d67 commit cbee086

3 files changed

Lines changed: 84 additions & 14 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"@vercel/og": "0.6.8",
9696
"chalk": "5.6.2",
9797
"glob": "13.0.0",
98+
"gray-matter": "4.0.3",
9899
"husky": "9.1.7",
99100
"json-schema-to-typescript": "15.0.4",
100101
"lint-staged": "16.0.0",

scripts/generate-library-covers.tsx

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2727
import path from 'node:path'
2828
import type { CSSProperties } from 'react'
2929
import { ImageResponse } from '@vercel/og'
30+
import matter from 'gray-matter'
3031
import { parse as parseFont } from 'opentype.js'
3132
import 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. */
112113
type 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+
*/
148215
function 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
*/
256332
function 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(/^title:\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

269337
async function main() {

0 commit comments

Comments
 (0)