typesnap
v0.1.0
Published
Zero-config font optimisation: detect external fonts at build time and generate preload tags, font-display rules, and metric-matched fallback stacks to eliminate layout shift.
Maintainers
Readme
TypeSnap
Zero-config font optimisation for the web. Scan your project, detect every external font, and generate preload tags,
font-displayrules, and metric-matched fallback stacks — automatically.
TypeSnap is a CLI and build plugin that eliminates font-induced Cumulative Layout Shift (CLS) from your site without making you hand-write preload tags or size-adjusted fallbacks. It statically analyses your project, figures out exactly which fonts are in use, and emits the three pieces you actually need: preloads, font-display, and metric-matched fallbacks.
Table of Contents
- Why TypeSnap
- Quick Start
- How It Works
- CLI Reference
- Build Plugins
- Programmatic API
- Configuration
- Detection Coverage
- Output Artifacts
- Lint Rules
- Supported Fonts (Metric Database)
- CI Integration
- Project Structure
- Development
- Roadmap
- License
Why TypeSnap
Fonts are the single most common source of layout shift on modern sites. Getting fonts right requires three pieces working in concert:
<link rel="preload">in the document<head>so the browser fetches font files early.font-display: swap(oroptional) on every@font-faceso text doesn't stay invisible forever.- A size-adjusted fallback font stack so the fallback renders at the same metrics as the real font — no reflow when the real font arrives.
All three are tedious to wire up by hand, all three are easy to get wrong, and none of them are zero-config today (yes, even with next/font). TypeSnap derives all three automatically from the fonts you've already declared.
What it is not. TypeSnap doesn't self-host fonts, doesn't download font files, doesn't modify your font files, and doesn't replace runtime loaders like the Web Font Loader. It's pure static analysis at build time.
Quick Start
# Run a scan and print a report
npx typesnap scan
# Generate preload tags, fallback CSS, and a JSON report
npx typesnap generate
# Inject the preloads into your HTML
npx typesnap inject public/index.html
# Fail CI if new issues appear
npx typesnap lint --fail-on warningInstall as a dev dependency if you want to use the build plugins:
npm install -D typesnap60-second walkthrough
Say your app/layout.tsx uses next/font/google for Inter, your styles.css has a custom @font-face for Clash Display, and your index.html loads Playfair Display from Google Fonts. Run:
npx typesnap generateTypeSnap will:
- Detect all three fonts, across all three file types, without any config.
- Write
public/typesnap-fallbacks.csscontaining an@font-faceblock per font withsize-adjust,ascent-override,descent-override, andline-gap-overridetuned to match Arial / Georgia / Courier New to each font's metrics. - Write
public/typesnap-preloads.htmlwith ready-to-paste<link rel="preconnect">and<link rel="preload">tags. - Write
.typesnap-report.jsonwith detected fonts, per-font CLS risk, and lint findings.
Import the generated CSS in your global stylesheet:
@import "./typesnap-fallbacks.css";Then chain the fallback family in your CSS:
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}Done. Your CLS from fonts is now ~0.
How It Works
Detection phase
TypeSnap walks your project (ignoring node_modules, .git, dist, .next, build, out, and a handful of other common output folders) and runs specialised parsers against each file type:
| File type | What it looks for |
|---|---|
| .css, .scss, .sass, .less, .pcss | @import url(...) to Google Fonts / Fontshare, @font-face blocks with font-family, src, font-weight, font-style, font-display |
| .html, .htm, .vue, .svelte, .astro | <link rel="stylesheet"> to font CDNs, <link rel="preload" as="font"> tags (used to reconcile "already has preload"), embedded <style> blocks |
| .js, .ts, .jsx, .tsx, .mjs, .cjs | next/font/google imports + calls (Inter, JetBrains_Mono, etc.), next/font/local imports, string literals containing Google Fonts or Fontshare URLs |
| tailwind.config.* | fontFamily theme entries (primary family becomes a detected font) |
Each detector emits DetectedFont records. The orchestrator then deduplicates by family name, merging weights, styles, and file paths, and picks the "highest-confidence" source as canonical (next-font-local > next-font-google > font-face > google > fontshare > tailwind).
Generation phase
For each detected font, TypeSnap emits:
- A preload tag. For local
.woff2/.woff/.ttffiles it emits<link rel="preload" as="font" type="..." crossorigin="anonymous">. For Google Fonts and Fontshare it emits a<link rel="preload" as="style">plus the original<link rel="stylesheet">— this pattern is the officially recommended one and avoids duplicate font-file downloads. font-displayinjection. For every@font-facemissingfont-display, TypeSnap records it as a lint warning and emits the configured default (swapby default) when generating.- A metric-matched fallback
@font-face. Using a built-in database of 30+ popular fonts (see below), TypeSnap writes a fallback block like:
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
ascent-override: 90.49%;
descent-override: 22.56%;
line-gap-override: 0%;
size-adjust: 107.64%;
}For fonts outside the built-in database, TypeSnap picks a category-based fallback (Arial for sans-serif, Georgia for serif, Courier New for monospace) and uses sensible default overrides. The fallback family is named "<Font Name> Fallback". Chain it in your CSS stack right after the real font:
font-family: "Inter", "Inter Fallback", sans-serif;Report phase
A .typesnap-report.json at your project root contains:
- The full list of detected fonts with source, weights, styles,
detectedInfiles, and booleans forhasPreload/hasFontDisplay/hasFallback. - A per-font CLS impact estimate (
low/medium/high). - The generated preload tags and fallback CSS blocks (so your CI or dashboard can inspect without running the tool again).
- Lint issues with severity, rule name, and human-readable message.
CLI Reference
typesnap scan
Detect fonts and print a report to the terminal.
typesnap scan [--cwd <path>] [--write] [--json]| Flag | Description |
|---|---|
| -c, --cwd <path> | Project root (default: current directory) |
| -w, --write | Also write report, fallback CSS, and preload HTML to disk |
| --json | Output machine-readable JSON instead of a pretty report |
Exits with code 1 if any error-severity issues were found.
typesnap generate
Write .typesnap-report.json, public/typesnap-fallbacks.css, and public/typesnap-preloads.html.
typesnap generate [--cwd <path>]typesnap inject [files...]
Inject preconnect + preload tags directly into HTML files. Idempotent — re-running skips tags that are already present, and a <!-- typesnap:inject --> marker keeps the managed block cleanly bounded.
typesnap inject [files...] [--cwd <path>]If no files are passed, TypeSnap auto-detects HTML files in public/, dist/, out/, and build/.
typesnap inspect
Pretty-print the most recent report (runs a fresh scan if none is cached).
typesnap inspect [--cwd <path>] [--report <path>]typesnap lint
Exit non-zero when font issues exceed a threshold. Good for CI.
typesnap lint [--cwd <path>] [--max-warnings <n>] [--fail-on <severity>]| Flag | Description |
|---|---|
| --max-warnings <n> | Fail when warnings exceed this count. -1 disables (default) |
| --fail-on <sev> | Fail on this severity or higher: info, warning, or error (default: warning) |
Build Plugins
Vite
// vite.config.ts
import { defineConfig } from 'vite';
import { typeSnapPlugin } from 'typesnap/vite';
export default defineConfig({
plugins: [
typeSnapPlugin({
fontDisplay: 'swap',
injectHtml: true,
}),
],
});- Runs on
buildStart, writes fallback CSS and a report. - On
transformIndexHtml, injects<link rel="preconnect">+<link rel="preload">into yourindex.htmlautomatically, inside a<!-- typesnap:inject -->block.
Next.js
// next.config.js
const { withTypeSnap } = require('typesnap/next');
module.exports = withTypeSnap(
{
// your existing next config
reactStrictMode: true,
},
{
fontDisplay: 'swap',
},
);- Runs during Webpack config, scans once per build.
- Writes fallback CSS to
public/typesnap-fallbacks.cssand the report to.typesnap-report.json. - Import the fallback CSS from your root layout or
_app.tsx.
Programmatic API
import { analyze, writeArtifacts } from 'typesnap';
const result = await analyze(process.cwd(), {
fontDisplay: 'optional',
exclude: ['DebugFont'],
});
console.log(result.report.stats.fontsDetected);
console.log(result.fallbackCss);
await writeArtifacts(result);Full surface (all exported from typesnap):
analyze(projectRoot, overrides?) => Promise<AnalyzeResult>writeArtifacts(result) => Promise<{ reportPath, fallbackPath, preloadPath }>injectIntoHtmlFile(filePath, preconnect, preloads) => Promise<{ injected, skipped }>scanProject(projectRoot, scanDirs) => Promise<ScanResult>generateFallbackBlock(font),generateFallbackStylesheet(fonts)generatePreloadTags(font),generatePreconnectTags(fonts)buildReport(params),generateLintIssues(fonts)lookupFontMetrics(family),getMetricsForFont(family),isKnownFont(family),listKnownFonts()loadConfig(projectRoot, overrides),detectScanDirs(projectRoot)
TypeScript types: DetectedFont, FontMetrics, FontSource, FontDisplay, TypeSnapReport, TypeSnapConfig, PreloadTag, FallbackBlock, LintIssue, ClsImpact, Severity.
Configuration
Zero config works out of the box. If you need to override, drop a typesnap.config.js, typesnap.config.mjs, typesnap.config.cjs, or typesnap.config.json at your project root.
// typesnap.config.js
/** @type {import('typesnap').PartialConfig} */
module.exports = {
fontDisplay: 'swap', // 'swap' | 'optional' | 'block' | 'fallback' | 'auto' — default: 'swap'
scanDirs: 'auto', // 'auto' | string[] — default: 'auto' (scans from projectRoot, ignores node_modules/.git/dist/.next/etc.)
exclude: ['DebugFont'], // Font families to ignore
outputDir: 'public', // Where to write typesnap-fallbacks.css and typesnap-preloads.html
injectPreloads: true, // Used by build plugins
};Detection Coverage
| Source | What's detected | How |
|---|---|---|
| Google Fonts (fonts.googleapis.com) | Family, weights (incl. :wght@ and :ital,wght@ axes), styles, display param | CSS @import, HTML <link>, JS string literals |
| Fontshare (api.fontshare.com) | Family, weights, display | CSS @import, HTML <link>, JS string literals (supports f= and f[]=) |
| Custom @font-face | Family, weight (single value or range like 200 700), style, font-display, src URLs | CSS parsers across .css/.scss/.sass/.less/.pcss + embedded <style> blocks |
| next/font/google | Family (from the imported function name), weights, styles, display, subsets | AST-like regex on import { Inter } from 'next/font/google' + subsequent const font = Inter({...}) calls |
| next/font/local | Family (derived from file path or variable), weights, styles, display, src paths | Same pattern against import localFont from 'next/font/local' |
| Tailwind config | Primary fontFamily entries (skipping system fonts) | Scans tailwind.config.js/ts/mjs/cjs for fontFamily: { ... } block |
| Adobe Fonts (Typekit) | Source detection only — surfaces the font but doesn't parse internal family list | URL-based heuristic |
Files whose names match typesnap-* or start with .typesnap are excluded from detection so TypeSnap's own output never feeds back into subsequent scans.
HTML entities (&, ", etc.) in href attributes are decoded before URL parsing, so multi-round inject/scan cycles are stable.
Output Artifacts
| File | Location | Purpose |
|---|---|---|
| .typesnap-report.json | Project root | Full detection + generation report, including per-font CLS estimates and lint issues |
| typesnap-fallbacks.css | public/ (configurable) | Metric-matched @font-face fallback blocks — @import this from your global CSS |
| typesnap-preloads.html | public/ (configurable) | Ready-to-paste preconnect + preload <link> tags |
Lint Rules
| Severity | Rule | Triggers when |
|---|---|---|
| warning | missing-preload | A font has no corresponding <link rel="preload"> tag |
| warning | missing-font-display | A custom @font-face has no font-display declaration |
| warning | missing-fallback | A font has no metric-matched fallback stack |
| warning | too-many-weights | A single family loads more than 4 weights (performance risk) |
| info | tailwind-only | A font is declared in Tailwind config but no @font-face or CDN import was found |
Use --fail-on warning in CI to block PRs that regress your font setup.
Supported Fonts (Metric Database)
TypeSnap ships with precomputed metric overrides for these families. For any other font, it falls back to sensible category-based defaults (serif → Georgia, monospace → Courier New, everything else → Arial).
Inter · Roboto · Roboto Mono · Open Sans · Lato · Poppins · Montserrat · Raleway · Nunito · Nunito Sans · Oswald · Source Sans Pro · Source Sans 3 · Merriweather · Playfair Display · DM Sans · DM Serif Display · IBM Plex Sans · IBM Plex Mono · Mulish · Ubuntu · Rubik · Work Sans · Plus Jakarta Sans · Karla · Fira Code · JetBrains Mono · Space Grotesk · Space Mono · Satoshi · Clash Display · Geist · Geist Mono
To extend: fork the repo and add entries to src/utils/fonts.ts. A PR with the source of the metrics (ideally Google Fonts' unitsPerEm / ascent / descent / x-height values) is welcome.
CI Integration
GitHub Actions
name: Fonts
on: [pull_request]
jobs:
typesnap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx typesnap lint --fail-on warningPre-commit hook (husky)
npx husky add .husky/pre-commit "npx typesnap lint --fail-on warning"Project Structure
typesnap/
├── src/
│ ├── cli.ts # CLI entrypoint (commander)
│ ├── core.ts # analyze() + writeArtifacts() + injectIntoHtmlFile()
│ ├── index.ts # Public API surface
│ ├── types.ts # Shared types
│ ├── commands/
│ │ ├── scan.ts # typesnap scan
│ │ ├── generate.ts # typesnap generate
│ │ ├── inject.ts # typesnap inject
│ │ ├── inspect.ts # typesnap inspect + renderInspect() for other commands
│ │ └── lint.ts # typesnap lint
│ ├── detectors/
│ │ ├── index.ts # orchestrator + deduplication
│ │ ├── css.ts # @import + @font-face parsing
│ │ ├── html.ts # <link> + entity decoding
│ │ ├── js.ts # next/font/google, next/font/local, URL literals
│ │ └── tailwind.ts # fontFamily config parsing
│ ├── generators/
│ │ ├── preload.ts # preload + preconnect tags
│ │ ├── fallback.ts # metric-matched @font-face blocks
│ │ └── report.ts # buildReport + generateLintIssues + CLS estimation
│ ├── plugins/
│ │ ├── vite.ts # Vite build plugin
│ │ └── next.ts # withTypeSnap() for next.config.js
│ └── utils/
│ ├── files.ts # walk(), writeFile(), path helpers
│ ├── config.ts # loadConfig() + detectScanDirs()
│ ├── fonts.ts # metric database + category inference
│ ├── logger.ts # picocolors wrappers
│ └── parse-font-url.ts # Google Fonts + Fontshare URL parsing
└── test/
└── fixtures/
└── sample-project/ # End-to-end test fixture
├── index.html
├── styles.css
├── tailwind.config.js
└── app/layout.tsxDevelopment
# Install
npm install
# Build (tsup → dist/)
npm run build
# Typecheck
npm run typecheck
# Rebuild on change
npm run dev
# Run against the bundled fixture
node dist/cli.js scan --cwd test/fixtures/sample-project
node dist/cli.js generate --cwd test/fixtures/sample-project
node dist/cli.js inspect --cwd test/fixtures/sample-project
node dist/cli.js lint --cwd test/fixtures/sample-project --fail-on warningThe fixture at test/fixtures/sample-project/ exercises every detector and every lint rule. Use it as a smoke test after any change to src/detectors/* or src/generators/*.
Stack
- Runtime: Node 18+
- Build: tsup (esbuild under the hood), emits ESM +
.d.ts - CLI parsing: commander
- Colors: picocolors (tiny, no ANSI escape-hatching needed)
- No runtime parser — everything is regex-based static analysis. This keeps TypeSnap fast (sub-200ms on most projects) and dependency-light.
Roadmap
| Status | Phase |
|---|---|
| ✅ Shipped | Phase 1 — CLI MVP: scan, generate, inspect, inject, lint |
| ✅ Shipped | Phase 2 — Vite + Next.js build plugins |
| 🚧 Planned | Phase 3 — Astro, SvelteKit, Nuxt adapters |
| 🚧 Planned | Phase 4 — Live metric fetching from Google Fonts for unlisted families |
| 🚧 Planned | Phase 5 — VS Code extension surfacing inline CLS warnings |
| 🚧 Planned | Phase 6 — typesnap doctor command: compare real CLS in a headless browser before/after generation |
License
MIT © Om Rajguru
TypeSnap is an original concept by Om Rajguru, April 2026.
