Broadening the Horizon: What Scripts Can Build Beyond the Toolchain
Series: The Scripts Deep-Dive, Part 4 of 4
The first three parts of this series stayed close to familiar territory: scripts that start servers, enforce code quality, synchronise submodules, generate API types. The things scripts are known for.
This post is about something different. It is about a class of script that most developers do not reach for, but that solves a real problem without needing a design tool, a content pipeline, or a language model. A script that does not manage the project: it builds something the project actually ships.
Every post on this site has a cover. Not a stock photo, not an AI-generated image, not a Figma file someone exported by hand. A branded SVG generated by a single Node.js script that reads the actual markdown, extracts what the post is distinctively about, resolves a relevant illustration from a public icon registry, and composes the result into a file ready to serve. The whole process takes roughly a second per post. The output is deterministic and reproducible from any git checkout. And it costs nothing beyond the time it took to write the script.
The pipeline is:
markdown ──▶ TF-IDF keywords ──▶ icon resolution ──▶ SVG compose
(override → search → fallback)Four stages. Four algorithms. No external service in the critical path at generation time, because the network results are disk-cached on first run. This is the design.
TL;DR: A 300-line Node.js script generates every cover image on this site: TF-IDF extracts the post's distinctive keywords, a curated map plus Sørensen-Dice fuzzy matching and Iconify search resolves an icon, two different hashes (a rolling hash and FNV-1a-seeded PRNG) pick a deterministic accent colour and glyph texture, and a template composes the final SVG. No AI, no design tool, and the same post always produces the same file, which is what makes it usable as a build step instead of a one-off manual task.
Why Not AI?
The question is worth answering directly, because AI image generation is the obvious alternative and "just use Midjourney" is what most people would reach for.
AI generation is non-deterministic. You cannot reproduce a cover from the commit that generated it. If you regenerate a post's cover six months later, you will get a different image. If you regenerate all covers because the template changed, every image on the site changes, whether or not the posts themselves changed. That is not a property a build pipeline can tolerate.
AI generation also does not read the post. It responds to a prompt you write about the post, which is work, and the quality of the result depends on how good the prompt is. A script that reads the actual markdown and extracts its distinctive vocabulary is doing more honest work with less human effort.
The question a generator script should answer is: what is this post distinctively about? That question has a classical, deterministic answer: TF-IDF.
Stage One: Extracting What a Post Is About
TF-IDF stands for Term Frequency–Inverse Document Frequency. It is a way of measuring how much a word characterises a specific document within a corpus. A word that appears often in one post but rarely in others scores high. A word that appears in every post scores near zero.
Before scoring, the text has to be cleaned. Raw markdown is full of syntax that would contaminate the results: frontmatter, fenced code blocks, inline backticks, link syntax, HTML tags, and Markdown punctuation. A stripMarkdown pass removes all of it, leaving only prose.
function stripMarkdown(md) {
return String(md)
.replace(/^---\n[\s\S]*?\n---/, '') // frontmatter
.replace(/```[\s\S]*?```/g, ' ') // fenced code
.replace(/`[^`]*`/g, ' ') // inline code
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text
.replace(/<[^>]+>/g, ' ') // html tags
.replace(/[#>*_~|=-]+/g, ' ') // markdown symbols
}After stripping, tokenize lowercases, splits on non-alphanumeric characters, and filters through a stopword set. The stopword set covers common English function words plus a secondary set of words that leak through from markdown entity encoding (amp, quot, href) that survive the strip pass and would otherwise score as high-frequency terms.
The title and tags get concatenated into the text two to three times before scoring. This is the weighting mechanism: a term in the title matters more than the same term buried in a paragraph. TF-IDF surfaces the signal; the weighting amplifies the right part of it.
const weighted = `${fm.title} ${fm.title} ${fm.title} ${fm.tags.join(' ')} ${fm.tags.join(' ')} ${fm.body}`The IDF component is built across all posts as a corpus. Each post's token set contributes to a document-frequency map, which the per-post scoring uses to divide rare terms up and common terms down:
// IDF: rarer across the corpus = higher score
const inv = Math.log((N + 1)) / Math.log(df + 1)
score = termFrequency * invThe top six terms by score are the keywords. For a post about HyperLogLog, they come out as hll, hyperloglog, cardinality, register, hash, bucket. For a post about Rust ownership, they are rust, heap, value, free, drop, move. The algorithm surfaces the vocabulary that is genuinely distinctive, not the vocabulary someone chose to write in a tag field (though the tags are weighted into the score), because that is what TF-IDF is designed to do.
Stage Two: Resolving an Icon
Keywords are terms. An illustration is an SVG. The gap between them is closed in three stages, each acting as a fallback for the one before it.
Stage 2a: The curated override
Some terms cannot be resolved by search. A public icon API asked for tokio returns something irrelevant; asked for ratatui, it returns nothing useful. These are coined names: brand terms, library names, invented words, that search engines handle poorly because the associations live in the developer community's shared context, not in the symbol's etymology.
The curated override is a small hand-maintained map of keywords to Iconify IDs. It does not match exactly; it matches using the Sørensen–Dice coefficient over character bigrams, at a threshold of 0.62. This allows borrowed to match borrow, benchmarks to match benchmark, and distributed to match distribute: near-misses that an exact lookup would miss, and that a language model would be overkill to handle.
const bigrams = (s) => {
s = s.toLowerCase().replace(/[^a-z0-9]/g, '')
const o = new Set()
for (let i = 0; i < s.length - 1; i++) o.add(s.slice(i, i + 2))
return o
}
const dice = (a, b) => {
const A = bigrams(a), B = bigrams(b)
let inter = 0
for (const g of A) if (B.has(g)) inter++
return (2 * inter) / (A.size + B.size)
}Stage 2b: Iconify search
If no curated entry matches above the threshold, each keyword is queried against the Iconify search API, filtered to monochrome icon sets: tabler, ph, lucide, solar, mdi, and others. Monochrome icons are used because the illustration needs to be recoloured to the post's accent at generation time, and icons from those sets are single-path or use currentColor, which makes colour substitution clean.
Results are disk-cached. The first run for a given keyword hits the network and writes to scripts/lib/.cache/. Every subsequent run reads the file. Deleting the cache directory forces re-resolution; this is the mechanism for updating icon choices when the library adds better options.
Stage 2c: Hash-based fallback
If no keyword resolves, because the post is about something the curated map does not cover and the search API returned nothing monochrome, a 32-bit hash of the keyword list is used to select deterministically from the curated map itself. The result will not be semantically relevant, but it will be consistent. The same post always gets the same fallback illustration across every run and every machine.
This is the weakest output the system produces, and it is worth being honest about it. A fallback cover shows an icon that has nothing to do with the post: it is chosen for stability, not meaning. That is the deliberate trade-off at the bottom of the pipeline: when relevance is unavailable, the system chooses determinism over a plausible-but-random guess, because a consistent wrong answer is debuggable and a different wrong answer every run is not. In practice the curated map and the search stage resolve the large majority of posts, so the fallback fires rarely, but when it does, it is visibly the seam in the system, and the fix is to add one curated entry rather than to make the fallback cleverer.
Stage Three: Deterministic Styling
Each cover has a unique colour accent and a unique random-character texture. Neither is chosen by a human, and both are stable: the same post produces the same colours on every run.
Two different hashes do this work, and it is worth being precise about which is which. The accent is selected by a fast 32-bit rolling hash of the slug: a Math.imul(h, 31) polynomial over the characters, the same family of hash you would recognise from Java's String.hashCode. The result is mapped to one of seven accent palettes:
const hash = (s) => {
let h = 0
for (const c of String(s)) h = (Math.imul(h, 31) + c.charCodeAt(0)) | 0
return Math.abs(h)
}The background texture uses a different, stronger mixer: an FNV-1a hash of the slug, recognisable by its offset basis 2166136261 and prime 16777619, to seed a mulberry32-style xorshift PRNG. That PRNG drives the texture: 120 glyphs (digits, brackets, operators, katakana, mathematical symbols) placed at positions, sizes, and opacities drawn from the seeded random stream.
const rand = (seed) => {
let h = 2166136261
for (const c of String(seed)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619) }
return () => {
h += 0x6d2b79f5
let t = Math.imul(h ^ (h >>> 15), 1 | h)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}A simple rolling hash is enough to fan a slug out across seven palettes; the texture wants better bit-mixing so neighbouring slugs do not produce visibly similar glyph fields, which is why FNV-1a seeds the PRNG. The glyph field is purely cosmetic, a noise layer that makes covers feel distinct even when the layout and icon are similar, but its determinism is what makes the whole system usable as a build step. Regenerating a cover produces the same file. The diff in git is meaningful.
Stage Four: Composing the SVG
With an icon and an accent in hand, the generator lays out a 1200×630 SVG in a single template pass. The left column carries the text; the right carries the illustration.
The headline is derived from the post title by splitting on the first colon, question mark, or full stop, and dropping any "Part N" suffix. The result is uppercased. This produces the large, readable text that SVG renders crisply at any resolution.
Laying out the headline requires fitting it into a fixed pixel width across a range of font sizes. The algorithm tries sizes from 64px down to 40px in steps, wrapping the text at each size using a greedy word-break algorithm and checking whether the result fits within three lines. The largest size that fits is used. If no size fits in three lines, the text wraps at 40px and the overflow is ellipsized at a word boundary.
function layoutHeadline(text, { maxWidthPx = 600, maxLines = 3 } = {}) {
for (const fs of [64, 58, 52, 46, 40]) {
const cpl = Math.max(6, Math.floor(maxWidthPx / (fs * 0.62)))
const lines = wrap(text, cpl)
if (lines.length <= maxLines) return { fs, lines }
}
// ellipsize at smallest size
const fs = 40
const cpl = Math.max(6, Math.floor(maxWidthPx / (fs * 0.62)))
const all = wrap(text, cpl)
const lines = all.slice(0, maxLines)
lines[maxLines - 1] = lines[maxLines - 1].replace(/\s*\S*$/, '') + '…'
return { fs, lines }
}The descriptor, two lines of subdued text below the headline, is taken from the first sentence of the metaDescription frontmatter field. It is wrapped at 52 characters, independent of font size, because it is always rendered at 24px. The accent underline bar between headline and descriptor is placed at a computed Y coordinate derived from the headline's line count, so layout blocks never overlap regardless of how the headline wraps.
The icon SVG is fetched from the Iconify API (or cache), stripped of its outer <svg> wrapper, and placed using a translate/scale transform calculated from the icon's viewBox. The colour is set at fetch time by passing the accent hex code as a query parameter to the Iconify API. The result is a single-colour path that takes the post's accent and composites cleanly over the dark background.
All user-facing text goes through an esc() function before being written into the SVG:
const esc = (s) => String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')A raw & in a title breaks the SVG XML, and a raw " breaks any value that lands in an attribute context. Escaping is not optional: it is what separates a generator that works from one that silently produces malformed files for posts with certain characters in their titles. The quote escape matters specifically because the descriptor and any attribute-bound string can carry punctuation the author never thinks about until a single post fails to render.
What the Script Replaces
Without a generation script, covers are manual work. That means: opening a design tool, applying a template, tweaking the text, exporting the file, naming it correctly, dropping it in the right directory, and committing it alongside the post. Sixteen steps instead of one. Multiply by every post. Multiply again every time the template changes and all the old covers need updating.
With the script, regenerating all covers is:
node scripts/gen-covers.mjsIt reads every markdown file in the content directory, builds the IDF across all of them, and emits one SVG per post into public/blog-covers/. A post with no cover yet gets one. A post whose cover is already current gets an identical file written, no change in git. A template change is applied uniformly across every post in a single run.
The script also does something a manual process cannot do: it reads the actual post. The icon and accent for a post about consistent hashing are different from the icon and accent for a post about offline-first architecture, because TF-IDF produced different keywords, which resolved to different illustrations, which were seeded with different hashes. The covers reflect the content because the generator reads the content. That is not a coincidence of good taste: it is a property of the algorithm.
Scripts Are Not Just Toolchain
This is the fourth and final part of a series that started with the argument that scripts are architecture. The first three parts showed that argument in the context of developer tooling: build systems, quality gates, coordination. Those are the areas where scripts are expected.
This post is about the area where scripts are not expected, and should be.
Content generation, asset pipelines, image production: these are domains most developers associate with design tools, CMS platforms, or AI services. They do not have to be. When the generation problem is well-defined, the inputs are structured, and the output is deterministic, a script is the right tool. It is faster than a human, cheaper than a service, and more reproducible than a model.
The test is not "can a script do this?" Scripts can do a surprising amount. The test is "is the problem mechanical enough that a human adding their taste in the loop adds less value than it costs in time?" For blog cover generation, where what you want is an accurate, branded, consistent visual that reflects each post's content, the answer is mostly yes, with the honest exception of the fallback covers, which is exactly where a human would still beat the script and where the curated map is the place to spend that human effort. Everywhere else, the algorithm is more accurate than a human skimming the post and picking a stock photo. The FNV-1a-seeded texture produces more consistent branding than a human eyeballing colours. The word-wrap solver is more reliable than a human adjusting font sizes in a GUI.
When the answer is yes, write the script.
Part 1: Scripts Are Not Shortcuts. They Are Architecture.
Part 2: Scripts as Quality Gates: How the Pre-Commit Hook Works
Part 3: Scripts as System Coordination: Managing a Monorepo with Node.js Scripts
Hiring or have a project?
Let's build something that holds.
Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.