6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
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()
|