How to auto-generate @font-face blocks in ci from your fonts folder
- Step 1List the fonts directory — `fs.readdir('src/fonts')` and filter to `.woff2`/`.woff`/`.ttf`. Group the entries by the (family, weight, style) you parse from each filename so one block can list multiple formats of the same face under a single `src`.
- Step 2Parse each filename into family / weight / style — Adopt a convention like `<Family>-<Weight>[Italic].<ext>`. Split on the last `-`, map the weight token to a number (see the Weight-name table), and detect a trailing `Italic`. Example: `Inter-SemiBoldItalic.woff2` → family `Inter`, weight 600, style italic, format woff2.
- Step 3Emit one block per (family, weight, style) group — For each group, build the `src` list in WOFF2 → WOFF → TTF order from the formats present, attach `format("woff2")`/`format("woff")`/`format("truetype")`, set the numeric `font-weight`, the `font-style`, and `font-display: swap`. This is exactly the block the generator produces — one per face.
- Step 4Pick the route: own template, or runner API — Route A: hold the `@font-face` string in your script (full control, zero dependencies). Route B: `POST` each face's options to `http://127.0.0.1:9789/v1/tools/font-face-generator/run` and concatenate the returned CSS — output matches the UI tool exactly. Either way you loop once per face.
- Step 5Write a single dist/fonts.css and import it once — Concatenate every block into `dist/fonts.css` and `@import` (or link) it from your global stylesheet. One file means one cache entry; browsers re-fetch only when the file's hash changes — i.e. only when fonts change.
- Step 6Wire it into prebuild / CI — Add `"prebuild": "node scripts/gen-fontface.mjs"` to `package.json`, or a GitHub Actions step before your build. Commit the generator script, not the output, so the CSS is always regenerated from the source-of-truth directory and never goes stale.
Filename token → font-weight number
A conventional weight-name map. Your parser turns the token after the last hyphen into the numeric font-weight the generated block uses. Matches the 100–900 range the generator accepts.
| Filename token | font-weight | Example file |
|---|---|---|
Thin | 100 | Inter-Thin.woff2 |
ExtraLight / UltraLight | 200 | Inter-ExtraLight.woff2 |
Light | 300 | Inter-Light.woff2 |
Regular / (none) | 400 | Inter-Regular.woff2 |
Medium | 500 | Inter-Medium.woff2 |
SemiBold / DemiBold | 600 | Inter-SemiBold.woff2 |
Bold | 700 | Inter-Bold.woff2 |
ExtraBold / UltraBold | 800 | Inter-ExtraBold.woff2 |
Black / Heavy | 900 | Inter-Black.woff2 |
Route A (own template) vs Route B (runner API)
Both produce the same block shape. Choose based on whether you want zero dependencies or guaranteed parity with the UI tool.
| Aspect | Route A — own Node template | Route B — JAD runner API |
|---|---|---|
| Where the logic lives | Your script (you control the template) | The generator handler, called over HTTP |
| Output parity with the UI | Up to you to match | Byte-identical to the generator |
| Per-call scope | You loop per face | You loop per face — one block per /run |
| Endpoint / call | n/a (pure Node) | POST 127.0.0.1:9789/v1/tools/font-face-generator/run |
| Inputs | Your parsed values | JSON: fontFamilyName, fontPath, fontWeight, fontStyle, fontDisplayValue, includeWoff2/Woff/Ttf, unicodeRangeCss |
| Dependencies | None beyond Node fs | JAD runner paired locally (no Python, no upload) |
Cookbook
Working snippets for both routes. Filenames are parsed to faces; each face becomes one block; blocks concatenate into dist/fonts.css.
Route A — walk the directory and emit blocks (Node)
ExamplePure Node, no dependencies. Reads /src/fonts, parses each filename, groups by face, and prints one @font-face per face with the formats it found.
import { readdirSync, writeFileSync } from "node:fs";
const W = { Thin:100, ExtraLight:200, Light:300, Regular:400,
Medium:500, SemiBold:600, Bold:700, ExtraBold:800, Black:900 };
const HINT = { woff2:'woff2', woff:'woff', ttf:'truetype' };
const ORDER = ['woff2','woff','ttf'];
const faces = {};
for (const f of readdirSync('src/fonts')) {
const m = f.match(/^(.+)-([A-Za-z]+?)(Italic)?\.(woff2|woff|ttf)$/);
if (!m) continue;
const [, fam, wname, ital, ext] = m;
const key = `${fam}|${W[wname]??400}|${ital?'italic':'normal'}`;
(faces[key] ??= { fam, w: W[wname]??400,
style: ital?'italic':'normal', exts: {}, stem: `/fonts/${f.replace(/\.[^.]+$/,'')}` });
faces[key].exts[ext] = true;
}
const css = Object.values(faces).map(f => {
const src = ORDER.filter(e => f.exts[e])
.map(e => `url("${f.stem}.${e}") format("${HINT[e]}")`).join(',\n ');
return `@font-face {\n font-family: "${f.fam}";\n src: ${src};\n font-weight: ${f.w};\n font-style: ${f.style};\n font-display: swap;\n}`;
}).join('\n\n');
writeFileSync('dist/fonts.css', css);Route B — drive the generator via the runner API
ExampleLoop over your faces and POST each one's options to the local runner. The returned CSS is identical to the UI tool's output, so hand-testing and CI agree. One block per call.
for (const face of faces) {
const res = await fetch(
'http://127.0.0.1:9789/v1/tools/font-face-generator/run',
{ method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ inputs: {
fontFamilyName: face.fam,
fontPath: face.stem, // no extension
fontWeight: face.weight, // 100..900
fontStyle: face.style, // normal|italic|oblique
fontDisplayValue: 'swap',
includeWoff2: true,
includeWoff: !!face.exts.woff,
includeTtf: !!face.exts.ttf
}}) });
blocks.push(await res.text());
}
// fs.writeFileSync('dist/fonts.css', blocks.join('\n\n'));package.json prebuild hook
ExampleRegenerate the CSS before every build so it can never go stale. Commit the script, not dist/fonts.css.
{
"scripts": {
"prebuild": "node scripts/gen-fontface.mjs",
"build": "vite build"
}
}
# npm run build → prebuild regenerates dist/fonts.css firstGitHub Actions step
ExampleRun the generator in CI ahead of the build. No Python, no font upload — Route A is pure Node; Route B needs the runner paired on the CI host.
- name: Generate @font-face CSS run: node scripts/gen-fontface.mjs - name: Build site run: npm run build # fonts.css is rebuilt from src/fonts on every run, # so a newly-added weight is always declared.
Italic pairing under one family
ExampleUpright and italic files share the family name; the parser sets font-style and the browser picks the right file. Two files, two blocks, one font-family.
src/fonts/Inter-Regular.woff2 -> weight 400, normal
src/fonts/Inter-RegularItalic.woff2 -> weight 400, italic
Emitted:
@font-face { font-family:"Inter"; font-weight:400; font-style:normal; src:url("/fonts/Inter-Regular.woff2") format("woff2"); font-display:swap; }
@font-face { font-family:"Inter"; font-weight:400; font-style:italic; src:url("/fonts/Inter-RegularItalic.woff2") format("woff2"); font-display:swap; }Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Filename has no weight token (Inter.woff2)
Defaults to RegularYour parser should fall back to font-weight: 400 when no recognised token follows the family name. The generator itself defaults fontWeight to 400 too, so a face named just Inter.woff2 becomes a Regular block. Decide this default deliberately — a missing token shouldn't crash the build.
Variable font file (Inter.var.woff2) in the directory
Needs special handlingThe generator emits one block per static weight; it has no variable-axis font-weight: 100 900 mode. A .var.woff2 shouldn't go through the per-weight loop — either skip it and hand-write a variable block, or freeze named instances with the Variable Font Freezer and feed those static files to the script.
Two files map to the same (family, weight, style)
Merge by designInter-Regular.woff2 and Inter-Regular.woff are the same face in two formats — group them into one block with a two-entry src list, not two blocks. Keying by family|weight|style (as in the Route A snippet) does this automatically; emitting per-file instead would produce duplicate @font-face rules.
Weight token the map doesn't know (Inter-Hairline.woff2)
UnmappedA token outside your weight map (Hairline, Book, vendor-specific names) yields no number. Fail loudly in CI (log the filename) or extend the map — silently defaulting to 400 will mislabel the face. The generator accepts any 100–900 value, so once you map the token correctly the block is fine.
Family name with a hyphen (IBM-Plex-Sans-Bold.woff2)
Parser ambiguitySplitting on the last hyphen handles this (IBM-Plex-Sans + Bold), but splitting on the first hyphen would break it. Use a regex that captures the weight token as the final segment, or switch to an underscore between family and weight to remove the ambiguity entirely.
Mixed-case extensions (Inter-Regular.WOFF2)
Normalise firstAn uppercase extension can slip past an .woff2 filter. Lower-case the extension before matching. The generated format() hint is always lowercase regardless, but your dedup keying and URL building should normalise the extension so two casings of the same file don't produce two blocks.
Spaces in filenames (Brand Sans-Bold.woff2)
URL-encodeSpaces are valid in family names but must be encoded in the src URL (%20). The family name in font-family keeps the space (quoted); the URL needs encoding. Decide whether to rename files to hyphenate or encode at emit time — inconsistency here causes 404s only in production.
Route B can't reach 127.0.0.1:9789
Runner not pairedRoute B requires the JAD runner running locally on the build host. If the connection refuses, the runner isn't paired/started on that machine (common on a fresh CI runner). Either pair it in the CI setup step or fall back to Route A's pure-Node template, which has no external dependency.
dist/fonts.css committed to git
AvoidCommit the generator script, not its output. If you commit dist/fonts.css, it drifts from src/fonts the moment someone adds a file without re-running the script — the exact problem the build step solves. Treat the CSS as a regenerated artefact and gitignore it.
size-adjust / ascent-override needed per face
Out of scope hereNeither the script nor the generator computes metric-override descriptors. If your design system needs zero-CLS fallback matching, compute size-adjust/ascent-override from each font's metrics (read them with the Font Metrics Analyzer) and append them in your template. See the best-practices guide for the pattern.
Frequently asked questions
Can one API call generate my whole multi-weight family?
No — each /run call (and each UI run) produces one block for one weight/style. To declare Regular + Medium + Bold, loop and call the generator once per face, then concatenate. There's deliberately no whole-family endpoint; the per-face shape keeps the output predictable and lets your script control grouping.
Will the runner API output match the UI tool exactly?
Yes — Route B calls the same font-face-generator handler the UI uses, so for identical inputs the CSS is byte-identical. That's the whole point of Route B: what you verify by hand in the generator UI is exactly what CI emits. Route A only matches if you replicate the template faithfully.
Does the runner upload my fonts anywhere?
No. The runner is a local process on 127.0.0.1:9789. For the @font-face Generator specifically there's nothing to upload anyway — it's generative and works from option values, not a font file. The CSS is produced on your machine and never round-trips to a server.
How should I encode weight in the filename?
Use <Family>-<Weight>.woff2, e.g. Inter-SemiBold.woff2. Map the token to a number with the table above (Regular→400, SemiBold→600, Bold→700, …). The generator accepts any 100–900 value, so once your parser produces the right number the block is correct. An underscore between family and weight avoids hyphen-in-family ambiguity.
How do I handle italics?
Suffix the weight token with Italic (Inter-RegularItalic.woff2), parse it to font-style: italic, and emit the block under the same font-family as the upright. The browser then picks the italic file when font-style: italic is requested. The generator's Style select (normal/italic/oblique) maps directly to this.
Can I set a different font-display per font?
Yes — font-display is a per-call option, so your script can pass swap for branding faces and optional for body faces. The generator's fontDisplayValue accepts all five CSS values (auto/block/swap/fallback/optional). Decide the policy in your script's config and pass it per face. The Font Display Strategy tool explains which to choose.
What about variable fonts in the pipeline?
The generator emits per-weight static blocks and has no font-weight: 100 900 variable mode. For a variable font, either hand-write the variable block once, or freeze the named instances you ship with the Variable Font Freezer and run those static files through the script. Don't push a .var.woff2 through the per-weight loop.
Should I commit the generated CSS?
No — commit the generator script and treat dist/fonts.css as a build artefact (gitignore it). Committing the output reintroduces drift: the file goes stale the moment someone adds a font without re-running the script, which is the exact bug the build step exists to prevent.
How do I add cache-busting hashes to the URLs?
Hash the font files at build time and incorporate the hash into the base path you pass as fontPath (e.g. /fonts/inter.8af3c1). The generator appends the format extension to whatever stem you give it, so a hashed stem produces hashed URLs. Most bundlers already emit content-hashed font filenames you can read back into the script.
Does the generator preserve unicode-range for subset blocks?
Yes — pass unicodeRangeCss in the inputs and it's emitted verbatim as a unicode-range line. For a per-subset pipeline, generate one block per (subset file, range) pair. Build the actual subset files with the Font Subsetter first, then point each block's base path at the matching subset.
Can this run with no Python and no toolchain install?
Yes. Route A is pure Node fs — nothing else. Route B needs only the JAD runner paired locally; it's not a Python toolchain. Neither route compiles or subsets fonts (this tool is generative), so there's no FontForge/fontTools dependency. If you also need to subset or convert in the same pipeline, see automating font subsetting.
How do I keep the output idempotent?
Sort the directory listing before emitting (so block order is stable), normalise extension casing, and don't inject timestamps or random IDs into the CSS. Identical fonts then produce an identical dist/fonts.css every run, which keeps content-hash cache keys stable and your diffs clean.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.