Back to work
Shipped v1.2.12026Maintainer & OSS Author90% complete

fontfetch (CLI + Core)

Paste a URL, get every webfont — extracted, license-classified, and project-ready. One npm install, zero runtime deps.

Summary

fontfetch is a published Node CLI (`npx fontfetch <url>`) and a shared TypeScript pipeline library (`@fontfetch/core`). It walks every stylesheet a browser would, parses every `@font-face`, downloads the binaries into a provenance-bucketed folder (`google/`, `adobe-typekit/`, `commercial/`, `open-cdn/`, `self-hosted/`), classifies each face as open / commercial / unknown, and emits a ready-to-paste `fonts.css` plus framework configs (Next.js `next/font/local`, Tailwind `fontFamily`, Vite guide). Three subcommands ship together — `pull` (default), `inspect` (terminal-native font report via fontkit), and `subset` (Playwright DOM walk + harfbuzzjs WASM subset, no Python `fonttools` install). Released on npm with Trusted Publishing (OIDC), CI across Linux/macOS/Windows × Node 18/20/22, and 101 passing vitest cases.

Target user

Web designers and front-end engineers prototyping with fonts they've seen in the wild — plus brand teams auditing what typography a live site actually serves. The CLI runs locally; no account, no telemetry.

§ 01Stack
01Primary
TypeScriptNode 18+ESM-only
02Infrastructure
pnpm 9 workspacestsupvitestGitHub Actionsnpm Trusted Publishing
03Integrations
fontkit@capsizecss/core + @capsizecss/unpackPlaywrightsubset-font / harfbuzzjs WASM
04UI / Frontend
Terminal outputMarkdown emit
§ 02Key features
  1. 01

    Shipped a zero-runtime-dep CLI on npm — `npx fontfetch <url>` works out of the box with no install step, Playwright and harfbuzzjs are lazy-loaded optional peers.

  2. 02

    Built `fontfetch inspect <file>` — terminal-native Wakamai Fondue equivalent printing glyph count, units-per-em, variation axes, OpenType features, name-table records, vendor, copyright, and SIL OFL detection with Reserved Font Name (RFN) clause awareness.

  3. 03

    Built `--fallback` — capsize-driven `size-adjust` / `ascent-override` / `descent-override` / `line-gap-override` blocks matched to a system fallback per family.

    Framework-agnostic CLS killer that works in any CSS surface (unlike `fontaine` which is Nuxt/Vite-only).

  4. 04

    Built `fontfetch subset <url>` — runs the full extraction, then loads the page in headless Chromium, walks every visible text node plus `::before`/`::after` `content`, and subsets each font to actually-rendered codepoints. Pure-Node via harfbuzzjs WASM, no Python `fonttools` dependency (unlike `glyphhanger`).

  5. 05

    Shipped a heuristic license classifier — URL-signature match against Adobe Typekit / Monotype `fast.fonts.net` / Hoefler `cloud.typography.com` / Type Network plus a curated SIL OFL / Google Fonts family-name fallback catalog. Fail-fast aborts on all-commercial pages and writes `LICENSE_REVIEW.md` only; `--force` to override.

  6. 06

    Built three framework emitters — `next.fonts.ts` (one `localFont` call per family with all weights), `tailwind.fonts.ts` (sans/serif/mono heuristic + per-family aliases), and a `vite.fonts.md` integration guide. Pluggable Emitter interface; add a new target in one file.

  7. 07

    Set up FAANG-grade open-core release infrastructure — manual `workflow_dispatch` release workflow with dry-run mode, version-bump guard, npm Trusted Publishing via OIDC (no long-lived publish token in the repo), automatic git tag + GitHub release on success.

  8. 08

    Designed a structured `PullProgressEvent` union and optional `onProgress` callback in `pull()` so non-CLI consumers can stream the pipeline live — same library powers a future webapp without any parallel codepaths.

§ 03Hardest problems
  1. Early users hit `1 unique file(s)` and assumed the rest were missing — actually one variable binary covered the whole family. Solved by inspecting every downloaded binary post-pull via fontkit, detecting any with variation axes, and printing a one-liner with the axis ranges (`wght 300..900, ital 0..10`). Non-fatal: parse failures are swallowed because the download itself already succeeded.

  2. The CLI imports from `@fontfetch/core` (a private workspace package), but npm consumers can't resolve `workspace:*`. Solved with `tsup`'s `noExternal: ['@fontfetch/core']` — core is inlined into the published `dist/cli.js`, so the tarball is a single self-contained file. Playwright stays an external optional peer so the static path runs with zero runtime deps.

§ 04What I learned
  • L01
    Heuristic classifiers should bias toward the safe failure mode.

    The license classifier prefers false-commercial over false-open — a user shipping a commercial face they thought was OFL has a legal problem; a user told their OFL face is commercial just runs `--force` and moves on.

  • L02
    Framework-agnostic beats framework-native when the underlying capability is identical.

    `--fallback` works in any CSS surface precisely because I resisted the urge to ship a Vite plugin or a next.config.js patcher. Each framework integration would be a smaller audience; the capability is the wedge.

  • L03
    Optional peer dependencies are how you keep a zero-install CLI feel while shipping power features.

    Playwright and `subset-font` are both opt-in — the help text tells you exactly what to install if you hit the path that needs them, and the static pipeline runs with nothing but Node.

§ 05By the numbers
npm package
fontfetch@1.2.1
versions shipped
14
vitest cases passing
101/101
source loc total
~3600
source loc lib
~2740
test loc
~860
framework emitters
3
subcommands
3
ci matrix
3 OS × 3 Node = 9 build configs