55 * top-left, a diagonal open arrow top-right, and the post title set large at
66 * the bottom-left. The same template is rendered at request time for docs
77 * pages by `apps/docs/app/api/og/route.tsx`; this script is the build-time
8- * equivalent for library posts, whose covers ship as static assets so
9- * `next/image` can optimize them and the SEO builders can probe their real
10- * dimensions.
8+ * equivalent for library posts, whose covers ship as static assets because
9+ * they are rendered with `unoptimized` (see #5528) and the SEO builders probe
10+ * their real dimensions off disk.
11+ *
12+ * Covers are derived artifacts, not source of truth: the title in the image
13+ * comes from frontmatter, so editing a post's `title` makes its committed
14+ * cover stale. Every run therefore re-renders from scratch rather than
15+ * skipping outputs that already exist. Rendering is deterministic — an
16+ * unchanged title re-encodes to identical bytes — so a full run is a no-op in
17+ * git for everything that did not actually change.
1118 *
1219 * Usage, from the repo root:
13- * bun run library:covers # write covers for posts that lack one
14- * bun run library:covers --force # rewrite every post's cover
15- * bun run library:covers <slug>... # rewrite only the named posts
20+ * bun run library:covers # re-render every post's cover
21+ * bun run library:covers <slug>... # re-render only the named posts
22+ * bun run library:covers --check # verify committed covers are in sync
1623 */
1724
1825import { existsSync } from 'node:fs'
@@ -218,7 +225,7 @@ function readFrontmatterTitle(source: string): string | null {
218225
219226async function main ( ) {
220227 const args = process . argv . slice ( 2 )
221- const force = args . includes ( '--force ' )
228+ const check = args . includes ( '--check ' )
222229 const only = new Set ( args . filter ( ( arg ) => ! arg . startsWith ( '--' ) ) )
223230
224231 const font = await readFile ( FONT_PATH )
@@ -239,25 +246,49 @@ async function main() {
239246 throw new Error ( `No library post for: ${ unknown . join ( ', ' ) } ` )
240247 }
241248
249+ const stale : string [ ] = [ ]
242250 let written = 0
251+
243252 for ( const slug of slugs ) {
244253 if ( only . size > 0 && ! only . has ( slug ) ) continue
245254
246255 const outputPath = path . join ( OUTPUT_DIR , slug , 'cover.jpg' )
247- if ( only . size === 0 && ! force && existsSync ( outputPath ) ) continue
248-
249256 const source = await readFile ( path . join ( CONTENT_DIR , slug , 'index.mdx' ) , 'utf8' )
250257 const title = readFrontmatterTitle ( source )
251258 if ( ! title ) {
252259 throw new Error ( `Could not read a \`title\` from frontmatter of ${ slug } /index.mdx` )
253260 }
254261
262+ const cover = await renderCover ( title , fontData , measure )
263+
264+ if ( check ) {
265+ const committed = existsSync ( outputPath ) ? await readFile ( outputPath ) : null
266+ if ( committed === null ) {
267+ stale . push ( `${ slug } — no cover committed` )
268+ } else if ( ! committed . equals ( cover ) ) {
269+ stale . push ( `${ slug } — committed cover does not match "${ title } "` )
270+ }
271+ continue
272+ }
273+
255274 await mkdir ( path . dirname ( outputPath ) , { recursive : true } )
256- await writeFile ( outputPath , await renderCover ( title , fontData , measure ) )
275+ await writeFile ( outputPath , cover )
257276 written += 1
258277 console . log ( `✓ ${ slug } /cover.jpg — "${ title } "` )
259278 }
260279
280+ if ( check ) {
281+ if ( stale . length > 0 ) {
282+ console . error ( `${ stale . length } library cover(s) out of sync with frontmatter:\n` )
283+ for ( const entry of stale ) console . error ( ` ✗ ${ entry } ` )
284+ console . error ( '\nRun `bun run library:covers` and commit the result.' )
285+ process . exitCode = 1
286+ return
287+ }
288+ console . log ( 'All library covers are in sync with their frontmatter titles.' )
289+ return
290+ }
291+
261292 console . log ( `\n${ written } cover${ written === 1 ? '' : 's' } written to apps/sim/public/library/` )
262293}
263294
0 commit comments