mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
* fix(library): generate every post cover from one template Three posts shipped an `ogImage` pointing at a file that was never committed, so the library index rendered broken images and their `og:image`, JSON-LD, and sitemap entries all 404'd. Several others were authored without the brand font loaded or with the title clipping off the bottom edge. Covers were hand-made per post with no generator, which is why they drifted. Adds `bun run library:covers`, rendering each cover from the post's frontmatter title using the reference template already encoded in the docs OG route, and regenerates all 20 so the grid is uniform. Line widths come from the font's real advance metrics rather than an average-glyph-width estimate: the template joins words with non-breaking spaces to dodge a Satori space-measurement bug, which leaves hyphens as the only fallback break points, so an under-measured line breaks mid-compound. Also drops six orphaned `cover.png` sources left over from the JPEG compression pass in #5528. * fix(library): re-render covers every run and add a sync check Covers are derived artifacts, so skipping outputs that already exist left an image showing the old title after a post's frontmatter `title` changed. Every run now re-renders from scratch; rendering is deterministic, so an unchanged title re-encodes to identical bytes and a full run stays a no-op in git. Replaces `--force` (now the default) with `--check`, which renders in memory and compares against the committed bytes without writing, so CI can catch both a stale cover and the missing-cover case that caused the original breakage. * fix(library): compare decoded pixels in the cover sync check Byte-equality on the mozjpeg output assumed portable encoder bytes. libvips/mozjpeg does not guarantee that across OS and CPU, so identical input can encode differently on a contributor's machine or a Linux CI runner and fail the check for no real reason — exactly where the check was meant to run. Decodes both images to greyscale and compares mean absolute difference instead, which discards encoder variance while still testing what the check is about. Measured on this cover set: re-encoding an identical render with a deliberately different encoder moves it ~0.26, a one-word title change moves it ~12; the threshold of 2 sits between them with ~8x margin. * fix(library): count redrawn pixels in the cover sync check Averaging the difference diluted a local edit across all 810,000 pixels. Changing a title's "2026" to "2027" moved the mean by 0.42 — under the tolerance that absorbed encoder noise — so the check passed a cover still showing the old year. Counts pixels that moved more than 48 greyscale levels instead. Measured on this cover set, that one-character edit redraws 2,559 pixels while three deliberately different encodes of an identical render (quality 60/70 without mozjpeg, quality 95 with) redraw none, so the count separates real drift from encoder variance in both directions. * 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.
413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
/**
|
||
* Generates the cover images for `/library` posts from their MDX frontmatter.
|
||
*
|
||
* Every cover follows one template: light gray field, the "sim" wordmark
|
||
* top-left, a diagonal open arrow top-right, and the post title set large at
|
||
* the bottom-left. The same template is rendered at request time for docs
|
||
* pages by `apps/docs/app/api/og/route.tsx`; this script is the build-time
|
||
* equivalent for library posts, whose covers ship as static assets because
|
||
* they are rendered with `unoptimized` (see #5528) and the SEO builders probe
|
||
* their real dimensions off disk.
|
||
*
|
||
* Covers are derived artifacts, not source of truth: the title in the image
|
||
* comes from frontmatter, so editing a post's `title` makes its committed
|
||
* cover stale. Every run therefore re-renders from scratch rather than
|
||
* skipping outputs that already exist. Rendering is deterministic on a given
|
||
* machine, so a full run is a no-op in git for everything that did not
|
||
* actually change.
|
||
*
|
||
* Usage, from the repo root:
|
||
* bun run library:covers # re-render every post's cover
|
||
* bun run library:covers <slug>... # re-render only the named posts
|
||
* bun run library:covers --check # verify committed covers are in sync
|
||
*/
|
||
|
||
import { existsSync } from 'node:fs'
|
||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import type { CSSProperties } from 'react'
|
||
import { ImageResponse } from '@vercel/og'
|
||
import matter from 'gray-matter'
|
||
import { parse as parseFont } from 'opentype.js'
|
||
import sharp from 'sharp'
|
||
|
||
const REPO_ROOT = path.resolve(import.meta.dirname, '..')
|
||
const CONTENT_DIR = path.join(REPO_ROOT, 'apps/sim/content/library')
|
||
const OUTPUT_DIR = path.join(REPO_ROOT, 'apps/sim/public/library')
|
||
/**
|
||
* Söhne Kräftig (weight 500), the typeface of the reference cover template,
|
||
* as a plain TTF — Satori (the renderer behind `ImageResponse`) parses neither
|
||
* WOFF2 nor variable fonts. Shared with the docs OG route, which serves this
|
||
* same file over HTTP because it runs on the edge with no filesystem.
|
||
*/
|
||
const FONT_PATH = path.join(REPO_ROOT, 'apps/docs/public/static/fonts/Soehne-Kraftig.ttf')
|
||
|
||
const COVER_WIDTH = 1200
|
||
const COVER_HEIGHT = 675
|
||
/**
|
||
* mozjpeg at 82 lands these covers around 30 KB — in line with the hand-compressed
|
||
* ones they replace. The artwork is a flat field plus large text, so the only detail
|
||
* the encoder has to preserve is glyph edges.
|
||
*/
|
||
const JPEG_QUALITY = 82
|
||
/**
|
||
* How far one greyscale pixel must move to count as genuinely redrawn rather
|
||
* than re-encoded. Measured across three deliberately different encodes of an
|
||
* identical render (quality 60/70 without mozjpeg, quality 95 with), the
|
||
* largest single-pixel deviation was 20; 48 clears that by well over 2x.
|
||
*/
|
||
const COVER_PIXEL_DELTA = 48
|
||
/**
|
||
* How many redrawn pixels `--check` tolerates before calling a cover stale.
|
||
*
|
||
* Deliberately a count and not an average. Averaging dilutes a local edit
|
||
* across all 810,000 pixels: changing a title's "2026" to "2027" moves the
|
||
* mean by only 0.42, which any threshold loose enough to absorb encoder noise
|
||
* would wave through. That same edit redraws 2,559 pixels, while the three
|
||
* re-encodes above redraw none at all — so a count separates the two cases
|
||
* with margin to spare in both directions.
|
||
*/
|
||
const MAX_REDRAWN_PIXELS = 200
|
||
|
||
/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */
|
||
const INK_COLOR = '#515151'
|
||
const BACKGROUND_COLOR = '#c1c1c1'
|
||
const TITLE_BOX_WIDTH = 1020
|
||
/** Tried largest-first; the first size whose title wraps into at most `MAX_TITLE_LINES` wins. */
|
||
const TITLE_FONT_SIZES = [110, 96, 85, 76] as const
|
||
/**
|
||
* Four lines of title crowd the wordmark and read as a paragraph rather than a
|
||
* headline. Titles too long to fit in three lines at the smallest step are set
|
||
* at that step anyway and allowed to run to a fourth line.
|
||
*/
|
||
const MAX_TITLE_LINES = 3
|
||
|
||
const CONTAINER_STYLE = {
|
||
height: '100%',
|
||
width: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
padding: '26px',
|
||
background: BACKGROUND_COLOR,
|
||
fontFamily: 'Soehne',
|
||
} satisfies CSSProperties
|
||
const HEADER_STYLE = {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
width: '100%',
|
||
} satisfies CSSProperties
|
||
const TITLE_STYLE = {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
fontWeight: 500,
|
||
color: INK_COLOR,
|
||
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
|
||
|
||
/** Measures a string's rendered width in pixels at `fontSize`, in the cover typeface. */
|
||
type TextMeasurer = (text: string, fontSize: number) => number
|
||
|
||
/** Greedily packs `pieces` into chunks that each measure within `TITLE_BOX_WIDTH`. */
|
||
function packChunks(pieces: string[], fontSize: number, measure: TextMeasurer): string[] {
|
||
const chunks: string[] = []
|
||
let current = ''
|
||
|
||
for (const piece of pieces) {
|
||
const candidate = current + piece
|
||
if (measure(candidate, fontSize) > TITLE_BOX_WIDTH && current) {
|
||
chunks.push(current)
|
||
current = piece
|
||
} else {
|
||
current = candidate
|
||
}
|
||
}
|
||
if (current) chunks.push(current)
|
||
|
||
return chunks
|
||
}
|
||
|
||
/**
|
||
* Breaks a single token that is wider than the title box on its own.
|
||
*
|
||
* `wrapTitleLines` can only break between space-separated words, so a long
|
||
* hyphenated compound ("Bring-Your-Own-Key-Management") would otherwise sit on
|
||
* a line that overflows the canvas — and since the rendered lines join with
|
||
* non-breaking spaces, Satori's only remaining break opportunity is a hyphen,
|
||
* putting the break somewhere nobody chose. Splitting here keeps that decision
|
||
* in this file.
|
||
*
|
||
* Hyphens are tried first because that is where a reader (and a browser)
|
||
* expects a compound to break; the trailing hyphen stays on the upper line.
|
||
* A chunk with no usable hyphen falls back to a character-level split, which
|
||
* only a pathological token (a long URL, an unbroken identifier) ever reaches.
|
||
*/
|
||
function splitOversizedWord(word: string, fontSize: number, measure: TextMeasurer): string[] {
|
||
const afterHyphens = packChunks(word.split(/(?<=-)/), fontSize, measure)
|
||
|
||
return afterHyphens.flatMap((chunk) =>
|
||
measure(chunk, fontSize) <= TITLE_BOX_WIDTH
|
||
? [chunk]
|
||
: packChunks([...chunk], fontSize, measure)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 has a
|
||
* text-measurement bug where the first plain space (U+0020) in a text node
|
||
* renders at roughly double width — a non-breaking space measures correctly
|
||
* and reads identically at this size, so it sidesteps the bug instead of
|
||
* fighting Satori's own line-wrapping.
|
||
*
|
||
* Because those non-breaking spaces leave Satori no word boundaries to break
|
||
* on, a line that turns out to overflow gets re-broken at whatever hyphen it
|
||
* happens to contain. That is why widths come from the font's real advance
|
||
* metrics rather than an average-glyph-width estimate: caps-heavy titles
|
||
* ("BYOK Multi-Model AI Agent") run ~15% wider than the average, and
|
||
* under-measuring one lands the break mid-compound in the rendered image.
|
||
*/
|
||
function wrapTitleLines(title: string, fontSize: number, measure: TextMeasurer): string[] {
|
||
const lines: string[] = []
|
||
let current = ''
|
||
|
||
for (const word of title.split(' ')) {
|
||
if (measure(word, fontSize) > TITLE_BOX_WIDTH) {
|
||
if (current) {
|
||
lines.push(current)
|
||
current = ''
|
||
}
|
||
const chunks = splitOversizedWord(word, fontSize, measure)
|
||
lines.push(...chunks.slice(0, -1))
|
||
current = chunks[chunks.length - 1] ?? ''
|
||
continue
|
||
}
|
||
|
||
const candidate = current ? `${current} ${word}` : word
|
||
if (measure(candidate, fontSize) > TITLE_BOX_WIDTH && current) {
|
||
lines.push(current)
|
||
current = word
|
||
} else {
|
||
current = candidate
|
||
}
|
||
}
|
||
if (current) lines.push(current)
|
||
|
||
return lines.map((line) => line.replace(/ /g, ' '))
|
||
}
|
||
|
||
/**
|
||
* Largest step whose title fits `MAX_TITLE_LINES` *and* whose every line fits
|
||
* `TITLE_BOX_WIDTH`, falling back to the smallest step.
|
||
*
|
||
* Both conditions matter. `wrapTitleLines` cannot break inside a token, so a
|
||
* single long word (a hyphenated compound like "Bring-Your-Own-Key", a URL)
|
||
* can exceed the box on its own and still produce few enough lines to pass a
|
||
* line-count-only test. That line would then overflow, and because the joined
|
||
* spaces are non-breaking, Satori's only recourse is to break it at a hyphen —
|
||
* reintroducing the mid-compound break this layout exists to avoid. Checking
|
||
* measured width catches it and steps the size down instead.
|
||
*/
|
||
function layoutTitle(title: string, measure: TextMeasurer): { fontSize: number; lines: string[] } {
|
||
let layout: { fontSize: number; lines: string[] } = {
|
||
fontSize: TITLE_FONT_SIZES[0],
|
||
lines: [title],
|
||
}
|
||
|
||
for (const fontSize of TITLE_FONT_SIZES) {
|
||
const lines = wrapTitleLines(title, fontSize, measure)
|
||
layout = { fontSize, lines }
|
||
|
||
const fitsBox = lines.every((line) => measure(line, fontSize) <= TITLE_BOX_WIDTH)
|
||
if (lines.length <= MAX_TITLE_LINES && fitsBox) break
|
||
}
|
||
|
||
return layout
|
||
}
|
||
|
||
/** "sim" wordmark, no icon — same brandbook wordmark geometry as the docs navbar/landing OG cards. */
|
||
function SimWordmark() {
|
||
return (
|
||
<svg width='118' height='57' viewBox='0 0 800 386' fill='none'>
|
||
<path
|
||
d='M0 293.75h53.4128c0 14.748 5.3413 26.506 16.0239 35.275 10.6826 8.37 25.1238 12.555 43.3233 12.555 19.783 0 35.016-3.786 45.698-11.36 10.683-7.971 16.024-18.534 16.024-31.687 0-9.566-2.967-17.538-8.902-23.915-5.539-6.378-15.826-11.559-30.861-15.545l-51.0389-11.958c-25.7173-6.377-44.9063-16.142-57.5672-29.296-12.2651-13.153-18.39771-30.491-18.39771-52.015 0-17.936 4.55001-33.481 13.64991-46.635 9.4957-13.153 22.3543-23.3169 38.576-30.4914 16.6173-7.1745 35.6086-10.7619 56.9739-10.7619 21.365 0 39.763 3.7866 55.193 11.3598 15.826 7.5731 28.091 18.1355 36.796 31.6875 9.1 13.552 13.847 29.695 14.243 48.428h-53.413c-.395-15.146-5.341-26.904-14.837-35.275-9.495-8.37-22.75-12.555-39.763-12.555-17.4083 0-30.8604 3.786-40.356 11.36-9.4956 7.573-14.2434 17.936-14.2434 31.089 0 19.531 14.2434 32.884 42.7304 40.058l51.039 12.556c24.53 5.58 42.928 14.747 55.193 27.502 12.265 12.356 18.398 29.296 18.398 50.82 0 18.335-4.946 34.477-14.837 48.428-9.891 13.552-23.541 24.114-40.95 31.687-17.013 7.175-37.191 10.762-60.534 10.762-34.0265 0-61.1285-8.37-81.3067-25.111-20.1782-16.74-30.2673-39.061-30.2673-66.962z'
|
||
fill={INK_COLOR}
|
||
/>
|
||
<path
|
||
d='m267.175 385.826v-292.3631c22.244 8.1331 32.053 8.1331 55.787 0v292.3631zm27.3-311.6891c-9.891 0-18.596-3.5872-26.113-10.7618-7.122-7.5731-10.683-16.342-10.683-26.3067 0-10.3632 3.561-19.132 10.683-26.3066 7.517-7.17453 16.222-10.7618 26.113-10.7618 10.287 0 18.991 3.58727 26.113 10.7618 7.122 7.1746 10.682 15.9434 10.682 26.3066 0 9.9647-3.56 18.7336-10.682 26.3067-7.122 7.1746-15.826 10.7618-26.113 10.7618z'
|
||
fill={INK_COLOR}
|
||
/>
|
||
<path
|
||
d='m421.362 385.823h-55.786v-292.3624h49.852v49.3294c5.934-16.342 17.408-30.197 33.234-40.959 16.222-11.1605 35.807-16.7407 58.754-16.7407 25.718 0 47.083 6.9752 64.096 20.9257 17.013 13.951 28.091 32.485 33.234 55.603h-10.089c3.957-23.118 14.837-41.652 32.642-55.603 17.804-13.9505 39.762-20.9257 65.875-20.9257 33.235 0 59.348 9.7653 78.339 29.2957 18.991 19.531 28.487 46.236 28.487 80.116v191.321h-54.6v-177.57c0-23.118-5.934-40.855-17.804-53.211-11.474-12.755-27.102-19.132-46.885-19.132-13.847 0-26.113 3.189-36.795 9.566-10.287 5.979-18.398 14.748-24.333 26.307-5.934 11.559-8.902 25.111-8.902 40.655v173.385h-55.193v-178.168c0-23.118-5.737-40.655-17.211-52.613-11.474-12.356-27.102-18.534-46.885-18.534-13.847 0-26.112 3.189-36.795 9.566-10.287 5.979-18.398 14.748-24.333 26.307-5.934 11.16-8.902 24.513-8.902 40.057z'
|
||
fill={INK_COLOR}
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/** 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='square'
|
||
strokeLinejoin='miter'
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
async function renderCover(
|
||
title: string,
|
||
fontData: ArrayBuffer,
|
||
measure: TextMeasurer
|
||
): Promise<Buffer> {
|
||
const { fontSize, lines } = layoutTitle(title, measure)
|
||
const image = new ImageResponse(
|
||
<div style={CONTAINER_STYLE}>
|
||
<div style={HEADER_STYLE}>
|
||
<SimWordmark />
|
||
<CornerArrow />
|
||
</div>
|
||
|
||
<div style={{ ...TITLE_STYLE, fontSize }}>
|
||
{lines.map((line, index) => (
|
||
<span key={index}>{line}</span>
|
||
))}
|
||
</div>
|
||
</div>,
|
||
{
|
||
width: COVER_WIDTH,
|
||
height: COVER_HEIGHT,
|
||
fonts: [{ name: 'Soehne', data: fontData, style: 'normal', weight: 500 }],
|
||
}
|
||
)
|
||
|
||
const png = Buffer.from(await image.arrayBuffer())
|
||
return await sharp(png).jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer()
|
||
}
|
||
|
||
/**
|
||
* Number of greyscale pixels the committed cover draws differently from a
|
||
* freshly rendered one — `null` if the committed file is not the expected size.
|
||
*
|
||
* Deliberately not a byte comparison of the JPEGs. libvips/mozjpeg output is
|
||
* not portable across OS and CPU, so identical input can encode to different
|
||
* bytes on a contributor's machine or a Linux CI runner and fail a byte-equal
|
||
* check for no real reason. Decoding first discards that encoder variance
|
||
* while preserving what the check is actually about: whether the committed
|
||
* image still renders the current title.
|
||
*/
|
||
async function countRedrawnPixels(committedPath: string, rendered: Buffer): Promise<number | null> {
|
||
const decode = (input: string | Buffer) =>
|
||
sharp(input).greyscale().raw().toBuffer({ resolveWithObject: true })
|
||
|
||
const [a, b] = await Promise.all([decode(committedPath), decode(rendered)])
|
||
if (a.info.width !== b.info.width || a.info.height !== b.info.height) return null
|
||
|
||
let redrawn = 0
|
||
for (let i = 0; i < a.data.length; i++) {
|
||
if (Math.abs(a.data[i] - b.data[i]) > COVER_PIXEL_DELTA) redrawn += 1
|
||
}
|
||
return redrawn
|
||
}
|
||
|
||
/**
|
||
* Reads the `title` out of an MDX file's YAML frontmatter without pulling in a
|
||
* YAML parser.
|
||
*
|
||
* Uses `gray-matter` rather than a regex because the page and its `og:title`
|
||
* are parsed with `gray-matter` too (`lib/content/registry-factory.ts`). A
|
||
* separate parser here could disagree with it on double-quoted escapes or
|
||
* block scalars, and the cover would then confidently render a title the page
|
||
* never shows — with `--check` calling it in sync.
|
||
*/
|
||
function readFrontmatterTitle(source: string): string | null {
|
||
const { title } = matter(source).data
|
||
return typeof title === 'string' && title.length > 0 ? title : null
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2)
|
||
const check = args.includes('--check')
|
||
const only = new Set(args.filter((arg) => !arg.startsWith('--')))
|
||
|
||
const font = await readFile(FONT_PATH)
|
||
const fontData = font.buffer.slice(
|
||
font.byteOffset,
|
||
font.byteOffset + font.byteLength
|
||
) as ArrayBuffer
|
||
const metrics = parseFont(fontData)
|
||
const measure: TextMeasurer = (text, fontSize) => metrics.getAdvanceWidth(text, fontSize)
|
||
|
||
const slugs = (await readdir(CONTENT_DIR, { withFileTypes: true }))
|
||
.filter((entry) => entry.isDirectory())
|
||
.map((entry) => entry.name)
|
||
.sort()
|
||
|
||
const unknown = [...only].filter((slug) => !slugs.includes(slug))
|
||
if (unknown.length > 0) {
|
||
throw new Error(`No library post for: ${unknown.join(', ')}`)
|
||
}
|
||
|
||
const stale: string[] = []
|
||
let written = 0
|
||
|
||
for (const slug of slugs) {
|
||
if (only.size > 0 && !only.has(slug)) continue
|
||
|
||
const outputPath = path.join(OUTPUT_DIR, slug, 'cover.jpg')
|
||
const source = await readFile(path.join(CONTENT_DIR, slug, 'index.mdx'), 'utf8')
|
||
const title = readFrontmatterTitle(source)
|
||
if (!title) {
|
||
throw new Error(`Could not read a \`title\` from frontmatter of ${slug}/index.mdx`)
|
||
}
|
||
|
||
const cover = await renderCover(title, fontData, measure)
|
||
|
||
if (check) {
|
||
if (!existsSync(outputPath)) {
|
||
stale.push(`${slug} — no cover committed`)
|
||
continue
|
||
}
|
||
const redrawn = await countRedrawnPixels(outputPath, cover)
|
||
if (redrawn === null) {
|
||
stale.push(`${slug} — committed cover has unexpected dimensions`)
|
||
} else if (redrawn > MAX_REDRAWN_PIXELS) {
|
||
stale.push(
|
||
`${slug} — committed cover does not render "${title}" (${redrawn} pixels differ)`
|
||
)
|
||
}
|
||
continue
|
||
}
|
||
|
||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||
await writeFile(outputPath, cover)
|
||
written += 1
|
||
console.log(`✓ ${slug}/cover.jpg — "${title}"`)
|
||
}
|
||
|
||
if (check) {
|
||
if (stale.length > 0) {
|
||
console.error(`${stale.length} library cover(s) out of sync with frontmatter:\n`)
|
||
for (const entry of stale) console.error(` ✗ ${entry}`)
|
||
console.error('\nRun `bun run library:covers` and commit the result.')
|
||
process.exitCode = 1
|
||
return
|
||
}
|
||
console.log('All library covers are in sync with their frontmatter titles.')
|
||
return
|
||
}
|
||
|
||
console.log(`\n${written} cover${written === 1 ? '' : 's'} written to apps/sim/public/library/`)
|
||
}
|
||
|
||
await main()
|