This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
vite-plugin-webfont-dl is a Vite plugin that downloads webfonts from third-party providers (Google Fonts, Bunny Fonts, Fontshare, jsDelivr, rsms.me) and self-hosts them. This eliminates render-blocking external requests, improves page load performance, and protects user privacy by preventing third-party tracking.
Core functionality:
- Extracts webfont URLs from HTML
<link>tags, plugin config, and CSS@importstatements - Downloads webfont CSS and parses
@font-facedefinitions - Downloads font files (woff2, woff, ttf, otf, eot, svg)
- Transforms CSS to reference local paths or embed fonts as base64
- Injects fonts into HTML as
<style>tags or external CSS files - Persistent file caching for offline development
Build:
npm run build # Production build with tsup (minified CJS + ESM + types)
npm start # Watch mode for developmentLint:
npm run lint # TypeScript check + ESLint (read-only)
npm run lint:fix # ESLint with auto-fixTest:
npm test # Run all Vitest testsThe main plugin file exports a Vite Plugin that implements four hooks:
configResolved: Captures Vite config (base URL, assets directory, minify settings, logger)configureServer: Sets up dev server middleware for serving fonts dynamicallytransformIndexHtml: Processes HTML files to extract and inject font referencesgenerateBundle: Main build-time processing (download, parse, transform, inject)
Critical detail: The plugin has two completely different execution flows:
- Build mode: Pre-downloads all fonts at bundle generation time
- Dev server mode: Serves fonts on-demand via middleware, lazy-loading as needed
The WebfontDownload class is the central coordinator that:
- Manages three webfont URL sources:
- Plugin config: URLs passed to the plugin constructor
- HTML extraction:
<link>tags parsed from HTML files - CSS extraction:
@importstatements found in bundled CSS
- Coordinates all components in sequence
- Maintains the font collection (Map<filename, Font>)
- Handles both dev server middleware and build-time processing
- Generates cache hit statistics for performance transparency
The plugin uses a component-based architecture with single-responsibility classes:
Purpose: Downloads webfont CSS from providers and normalizes URLs
Key implementation details:
- Uses FileCache for persistent storage
- URL normalization edge cases:
- Protocol-relative URLs (
//fonts.googleapis.com/...) → prefixed withhttps: - Relative URLs (
../fonts/,./fonts/,fonts/) → resolved to absolute URLs using CSS file's origin - Fully-qualified URLs → used as-is
- Protocol-relative URLs (
- Flash logging shows download progress in terminal
Purpose: Parses CSS to extract font URLs and @font-face definitions
Key implementation details:
- Two regex patterns for font URLs:
- Standard font URLs:
https://fonts.gstatic.com/.../font.woff2 - Google Fonts Kit URLs:
https://fonts.gstatic.com/l/font?kit=...(special format)
- Standard font URLs:
- Google Fonts Kit handling:
- Extracts
kitparameter as filename - If kit string >50 chars, generates SHA1 hash as filename
- Always uses
.woff2extension
- Extracts
- Subset filtering:
- Google Fonts CSS includes comments like
/* latin */before@font-faceblocks - When
subsetsAllowedoption is set, only fonts with matching comment tags are included - Reduces font files downloaded by filtering out unwanted language subsets
- Google Fonts CSS includes comments like
parseBundleCssmethod (separate fromparse):- Scans user's bundled CSS for webfont references
- Extracts both
@font-facedefinitions and@importstatements - Only processes fonts from whitelisted providers (security measure)
- Handles three
@importsyntax variants:@import url('...');@import"...";@import '...';
- Provider whitelist: Google Fonts, Bunny Fonts, Fontshare, Google Fonts Static
- Returns deduplicated fonts via Map (filename as key ensures uniqueness)
Purpose: Replaces remote font URLs with local paths or embeds as base64
Key implementation details:
- Two transformation modes:
- URL replacement: Replaces
https://fonts.gstatic.com/.../font.woff2with/assets/font.woff2 - Base64 embedding: Converts font to
data:font/woff2;base64,...
- URL replacement: Replaces
- MIME type mapping: Each font extension has correct MIME type (woff2→font/woff2, svg→image/svg+xml, etc.)
- Regex escaping: Font URLs are escaped before regex replacement to handle special characters in query strings
- Minification: Uses CleanCSS library, only applied in build mode when
minifyCss: true
Purpose: Injects transformed CSS into HTML
Key implementation details:
- Three injection modes:
- Inline style tag (default):
<style>...</style>in<head> - Async external stylesheet: Uses
media="print"trick withonloadhandler for non-blocking load - Sync external stylesheet: Standard
<link rel="stylesheet">
- Inline style tag (default):
- Indentation preservation:
- Regex captures existing indentation from
</head>tag - Injects CSS with matching indentation to preserve HTML formatting
- For minified HTML/CSS, injects without extra whitespace
- Regex captures existing indentation from
- Async loading technique:
<link rel="preload" as="style" href="/assets/webfonts.css"> <link rel="stylesheet" media="print" onload="this.onload=null;this.removeAttribute('media');" href="/assets/webfonts.css">
- First link preloads the CSS file
- Second link loads as print media (non-blocking), then promotes to all media via JS
Purpose: Downloads font binary files
Key implementation details:
- Simple wrapper around Downloader with FileCache integration
- Uses flash logging to show progress
- Returns Buffer for binary font data
Purpose: Extracts webfont <link> tags from HTML and removes them
Key implementation details:
- Handles both attribute orders:
<link rel="stylesheet" href="..."><link href="..." rel="stylesheet">
- Supports 5 providers: Google Fonts, Bunny Fonts, Fontshare, jsDelivr, rsms.me (Inter font)
- Comment detection: Skips tags inside HTML comments
- Regex captures
(<!--.*?)?before<link> - Checks if captured group contains
-->to determine if tag is commented out - Only processes uncommented tags
- Regex captures
- Removes preconnect hints: Also removes
<link rel="preconnect">tags for font domains - Newline cleanup: When removing tags, also removes trailing newlines to avoid blank lines in output
Purpose: HTTP client for downloading CSS and fonts
Key implementation details:
- User-Agent: Uses Chrome 77 UA string to ensure Google Fonts serves woff2 format
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.0.0 Safari/537.36
- IPv4-only: Sets
family: 4on HTTP agents to avoid IPv6 connection issues - Keep-alive connections: Reuses TCP connections for better performance
- Retry logic:
- Max 3 attempts per request
- Random wait interval between retries: 25-2500ms
- Logs success with try number if retry was needed
- Throws error after 3 failed attempts
- Timeout: 2500ms per request
- Proxy support: Accepts AxiosProxyConfig for corporate proxy environments
- Response types:
- Default:
arraybuffer(for binary fonts) - Optional:
text(for CSS files)
- Default:
Purpose: Persistent file-based cache for CSS and fonts
Key implementation details:
- Cache location: Respects Vite's
cacheDirconfiguration (defaults tonode_modules/.vite/cache/)- Directory is set via
setCacheDir()method called inconfigResolvedhook - Supports custom cache locations (e.g.,
.yarn/.cachefor Yarn PnP) - Cache is reinitialized if directory changes
- Directory is set via
- Cache versioning: Filename includes plugin version (
plugin-webfont-dl_${version}.json)- Automatically invalidates cache when plugin updates
- Prevents issues from format changes between versions
- Dual data types:
- CSS: Stored as strings
- Fonts: Stored as Buffers
- Buffer serialization:
- flat-cache stores everything as JSON
- Buffers are serialized as
{type: 'Buffer', data: [...]} - Deserialized back to Buffer on retrieval using
Buffer.from()
- Cache hit tracking: Maintains separate counters for CSS and font cache hits
- Cache clearing: When
cache: false, existing cache is deleted on initialization - Save strategy: Saves immediately after each write (
cache.save(true))
Purpose: Logging with Vite integration and TTY support
Key implementation details:
- Flash line feature: Shows transient progress messages
- Only works in TTY environments (not in CI)
- Clears line after 500ms timeout
- Truncates output to terminal width to prevent wrapping
- Falls back to regular logging in non-TTY environments
- Prefix: All messages prefixed with
[webfont-dl]in dim color - Vite logger integration: Uses Vite's logger when available for consistency
- TTY detection: Checks
stdout.isTTY && !env.CIto determine terminal capabilities
Purpose: Default configuration values
Default values:
{
injectAsStyleTag: true, // Inline CSS in <style> tag
minifyCss: true, // Minify CSS (overridden by build.minify)
embedFonts: false, // Don't embed as base64
async: true, // Use async loading for external CSS
cache: true, // Enable persistent cache
proxy: false, // No proxy
assetsSubfolder: '', // No subfolder
throwError: false, // Log errors as warnings, don't stop build
subsetsAllowed: [], // Allow all subsets
}- Data structure:
Map<string, Font>where key is filename - Why Map: Ensures uniqueness, prevents duplicate downloads
- Filename collision: Later fonts with same filename overwrite earlier ones (intentional)
Multiple path variables interact:
base: Vite's base URL (e.g.,/or/app/)assetsDir: Output directory for assets (e.g.,assets), changes in dev mode to@webfontsassetsSubfolder: Optional user-configured subfolder within assets- Security:
assetsSubfolderis sanitized:- Leading/trailing slashes removed
- Path traversal (
../) blocked, reverts to empty string
Build Mode (isDevServer = false):
- All fonts pre-downloaded during
generateBundlehook - CSS can be inline
<style>or external.cssfile - Font paths include
baseandassetsDir
Dev Server Mode (isDevServer = true):
assetsDirchanged to@webfonts(virtual directory)- Two middleware handlers:
- CSS handler:
/@webfonts/webfonts.cssand/webfonts.css(Laravel compat) - Font handler: Matches font requests against
fontUrlsDevMap
- CSS handler:
- Fonts downloaded on-demand when requested
- CSS is always external, never inline
- Font downloads cached in
fontUrlsDevMapfor subsequent requests
throwErroroption: Controls build behavior on errorsfalse(default): Logs error as warning, continues buildtrue: Throws error, stops build
- Axios errors: Extracts request details (method, protocol, host, path) for debugging
- Graceful degradation: If fonts fail to download, build continues without them (unless
throwError: true)
- Commented tags:
<!-- <link href="..."> -->are ignored - Multi-line comments: Handles comments spanning multiple lines
- Nested HTML: Regex handles complex HTML structures
- Self-closing tags: Supports both
<link>and<link /> - Quote variations: Handles single quotes, double quotes, and no quotes
- Protocol-relative URLs:
//fonts.googleapis.com/...converted tohttps://... - Relative paths: Resolved relative to CSS file's location
- Query strings: Preserved in font URLs (e.g.,
?v=7.0.96) - Google Fonts Kit URLs: Special handling for
?kit=parameter - Unicode ranges: Preserved in
@font-facedefinitions - Subset comments: Used for filtering, preserved in output
The injector preserves HTML indentation for aesthetic reasons:
// Captures indentation from </head> tag
/([ \t]*)<\/head>/
// Injects with matched indentation
`$1$1<style>\n${css.replace(/^/gm, '$1$1$1')}\n$1$1</style>\n$1</head>`For minified HTML, injects without extra whitespace.
Problem: When extracting @import URLs from CSS, the regex captured the closing parenthesis as part of the URL:
@import url(https://fonts.googleapis.com/font.css);- Captured:
https://fonts.googleapis.com/font.css)← extra)
Result: The malformed URL returned a 404 error page from Google Fonts (HTML with CSS styles), which the plugin then extracted and injected into the output, causing unwanted code, html, body, .projectLogo styles to appear.
Fix: Updated regex in css-parser.ts line 97 to exclude ) from URL capture:
// Before: ([^\s'"]+)
// After: ([^\s'")]+)Problem: Chrome extensions enforce strict Content Security Policy that prohibits inline event handlers. The plugin's async CSS injection uses onload="this.onload=null;this.removeAttribute('media');" which violates CSP.
Solution: The plugin already supports CSP-compliant mode! Set async: false to use injectSync() which has no inline handlers.
Configuration for Chrome Extensions:
webfontDownload([], {
injectAsStyleTag: false,
async: false, // CSP-compliant, no inline event handlers
})Alternative: Use injectAsStyleTag: true to inline CSS as a <style> tag (also CSP-compliant).
Testing: Added comprehensive tests in test/css-parser-import-bug.test.ts to verify all @import syntax variants work correctly.
- Location:
test/directory - Fixtures:
test/fixtures/contains sample HTML and CSS files - Framework: Vitest 4
- Configuration:
vitest.config.tssets test directory andsrc/alias for imports
-
Component tests: Each component has dedicated test file
css-loader.test.ts: URL normalizationcss-parser.test.ts: Font extraction, subset filteringcss-transformer.test.ts: URL replacement, base64 embedding, minificationcss-injector.test.ts: HTML injection, indentation preservationindex-html-processor.test.ts: Tag extraction and removal for all providerssave.test.ts: File emission and path generation
-
Fixture files: Real-world examples
google-fonts.css: Full Google Fonts CSS with all subsetsgoogle-fonts-kit.css: Google Fonts Kit URL formatimports.css: Three@importsyntax variantspre-normalization.css/post-normalization.css: URL resolution examples- HTML fixtures for each provider (Google, Bunny, Fontshare, jsDelivr, rsms.me)
- Snapshot testing: Compares transformed output to expected fixtures
- Subset filtering: Verifies only specified subsets are included
- Indentation tests: Checks both formatted and minified HTML
- Provider coverage: Tests all supported webfont providers
- Comment handling: Verifies commented tags are ignored
- Entry:
src/index.ts - Formats: CJS (
.js) and ESM (.mjs) - Type definitions: Generated (
.d.ts) - Minification: Only in production (disabled in watch mode)
- Clean: Removes
dist/before each build
dist/
├── index.js # CommonJS
├── index.mjs # ESM
└── index.d.ts # TypeScript definitions
{
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
}Multiple export names for flexibility:
default(main export)webfontDlwebfontDownloadviteWebfontDlViteWebfontDownloadviteWebfontDownload
- Extends:
eslint:recommended@typescript-eslint/recommended-type-checked@typescript-eslint/stylistic-type-checked
- Parser: TypeScript ESLint parser with type-aware linting
- Rules:
- Trailing commas required in multiline constructs
- Single quotes preferred
- Semicolons required
- Semicolon spacing enforced
- Ignores:
dist/,node_modules/
- Target: ES2018
- Module: ESNext with bundler resolution (required for Vite 8's exports-only package)
- Strict mode: Enabled with strict null checks
- noUnusedLocals: Enforced for clean code
- Include:
src/,test/, config files - Exclude:
dist/
- Vite 8 is rolldown-based; its plugin hook types differ from rollup's
- Never import types from
rollup— use the minimal structural types insrc/types.d.ts(EmittedAsset,EmitFile,OutputAsset,OutputBundle), which are compatible with both rollup (Vite ≤7) and rolldown (Vite 8+) dist/index.d.tsmust only reference thePlugintype fromvite, so consumers on any Vite version (2–8) can type-check
- Add URL patterns to
IndexHtmlProcessor.webfontRegexes(both attribute orders) - Add domain to
IndexHtmlProcessor.preconnectRegexes - Add domain to
CssParser.webfontProviderswhitelist - Create fixture files in
test/fixtures/ - Add tests in
test/index-html-processor.test.ts
- Be aware of URL normalization edge cases
- Test with protocol-relative and relative URLs
- Consider Google Fonts Kit URL format
- Verify subset filtering still works
- Check that duplicate filenames are handled correctly
- Test both minified and formatted HTML
- Verify indentation preservation
- Test all three injection modes (inline, async, sync)
- Check that base paths are correctly applied
- Cache filename includes version - increment version on breaking changes
- Test both string (CSS) and Buffer (font) serialization
- Remember cache is disabled in tests by default
- Middleware runs before Vite's built-in middleware
- Dev server uses virtual
@webfonts/directory - Laravel Vite Plugin compatibility requires
/webfonts.cssroute - Font URLs are stored in
fontUrlsDevMapfor on-demand serving - Dev server never uses inline styles, always external CSS
- Path traversal in
assetsSubfolderis blocked - Only whitelisted providers are processed from user CSS
- HTML comment tags are ignored to prevent injection via comments