Files
simstudioai--sim/scripts/generate-library-covers.tsx
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

413 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()