Skip to content

Add font optimization plugin#348

Draft
WendellAdriel wants to merge 9 commits into3.xfrom
feat/font-optimization
Draft

Add font optimization plugin#348
WendellAdriel wants to merge 9 commits into3.xfrom
feat/font-optimization

Conversation

@WendellAdriel
Copy link
Copy Markdown
Member

@WendellAdriel WendellAdriel commented Apr 7, 2026

Summary

  • Add font optimization plugin that downloads fonts from Google, Bunny, Fontsource, and local providers at build time, generating @font-face rules, CSS variables, utility classes, and a manifest for the framework runtime
  • Support font preloading, fallback font metrics (via fontaine), per-family CSS fragments for selective loading, and unicode-range subsetting
  • Dev server middleware serves cached fonts through /__laravel_vite_plugin__/fonts/ with proper CORS and caching headers
  • Production builds emit absolute asset paths in CSS so fonts resolve correctly when inlined into <style> tags

Examples

Basic usage

import laravel from 'laravel-vite-plugin'
import { google } from 'laravel-vite-plugin/fonts'

export default defineConfig({
  plugins: [
    laravel({
      input: 'resources/js/app.js',
      fonts: [
        google('Inter'),
      ],
    }),
  ],
})

Single remote font with full API

import laravel from 'laravel-vite-plugin'
import { google } from 'laravel-vite-plugin/fonts'

export default defineConfig({
  plugins: [
    laravel({
      input: 'resources/js/app.js',
      fonts: [
        google('Inter', {
          alias: 'sans',
          variable: '--font-sans',
          tailwind: 'sans',
          weights: [400, 500, 600, 700],
          styles: ['normal', 'italic'],
          subsets: ['latin', 'latin-ext'],
          display: 'swap',
          preload: [
            { weight: 400, style: 'normal' },
            { weight: 700, style: 'normal' },
          ],
          fallbacks: ['system-ui', 'sans-serif'],
          optimizedFallbacks: true,
        }),
      ],
    }),
  ],
})

Advanced local font

import laravel from 'laravel-vite-plugin'
import { local } from 'laravel-vite-plugin/fonts'

export default defineConfig({
  plugins: [
    laravel({
      input: 'resources/js/app.js',
      fonts: [
        // Explicit variants for full control
        local('Brand Sans', {
          alias: 'brand',
          variable: '--font-brand',
          tailwind: 'sans',
          display: 'swap',
          variants: [
            { src: 'resources/fonts/BrandSans-Regular.woff2', weight: 400 },
            { src: 'resources/fonts/BrandSans-Italic.woff2', weight: 400, style: 'italic' },
            { src: ['resources/fonts/BrandSans-Bold.woff2', 'resources/fonts/BrandSans-Bold.ttf'], weight: 700 },
          ],
          preload: [{ weight: 400, style: 'normal' }],
          fallbacks: ['system-ui', 'sans-serif'],
        }),

        // Shorthand — auto-discovers files and infers weight/style from filenames
        local('Icons', {
          src: 'resources/fonts/icons/*.woff2',
          alias: 'icons',
          preload: false,
        }),
      ],
    }),
  ],
})

Introduces a `fonts` option to the plugin config with support for
four providers: local(), google(), bunny(), and fontsource().

Build mode: resolves font families, emits assets, generates
@font-face CSS with fallback metrics, and writes a
fonts-manifest.json for Laravel consumption.

Dev mode: eagerly resolves fonts on server start, writes an inline
hot manifest, and serves font files via plugin middleware.

Exported as a separate entry point at laravel-vite-plugin/fonts.
Includes 80 new tests covering config, CSS generation, manifest
building, caching, dev server, and CSS parsing.
The framework needs to filter font CSS by family without regex-parsing
the generated output. Emit familyStyles (per-family @font-face +
fallback CSS) and variables (shared :root block) alongside the existing
full CSS reference so the framework can select fragments directly.
- Merge variant files sharing the same weight:style instead of
  overwriting, preserving all unicode-range subsets
- Reject duplicate family names in config validation since the
  manifest is keyed by family name
- Include configured subsets in Google/Bunny CSS2 request URLs
- Extract shared manifest logic into resolveEntries helper
Generates a `.font-*` utility class per configured font family, derived
from the CSS variable name (e.g. `--font-sans` → `.font-sans`). Classes
reference the CSS variable so fallback chains are included automatically.
Appended to both full CSS output and per-family `familyStyles` fragments
so partial loading via `@fonts(['Inter'])` works correctly.
- Register font middleware in configureServer hook instead of the
  httpServer 'listening' callback so it runs before Vite's SPA
  fallback middleware, which was swallowing all font requests with 404
- Use createRequire from 'module' package for fontsource resolution
  since the bundled ESM output lacks require.resolve support
- Fix fontsource CSS filename pattern to match the actual convention
  used by @fontsource packages (subset-weight.css, not
  subset-weight-style.css)
- Emit absolute URL paths in font CSS and familyStyles so they resolve
  correctly when inlined into <style> tags in production builds
@WendellAdriel WendellAdriel marked this pull request as draft April 7, 2026 18:13
Replace nested FontConfig/FontProviderConfig model with flat
FontDefinition where provider functions (google, bunny, fontsource,
local) return complete definitions. Introduce alias-based keying for
manifests and CSS variables, definition merging for incremental
weight/style composition, selective preload control, and explicit
fallback font stacks. Consolidate per-provider files into unified
modules.
The local() provider now supports three shorthand modes in addition to
explicit variants:

- Single file: src pointing at one font file
- Directory: src pointing at a directory for recursive discovery
- Glob: src with a glob pattern to filter discovered files

Weight and style are inferred from filenames when omitted from variant
definitions. Files with the same weight+style but different formats are
grouped into a single variant with format preference ordering.
- Fix mixed unicode-range variants silently dropping non-ranged files
- Validate CSS variable names must be non-empty and start with "--"
- Reject incompatible local font shape merges (src vs variants)
- Fix false italic inference from filenames ending in "it" (e.g. Split)
- Normalize explicit variant source format priority to match shorthand
- Scope dev manifest cleanup per instance instead of module-global
- Extract shared helpers in css.ts and cache.ts to reduce duplication
Copy link
Copy Markdown

@joetannenbaum joetannenbaum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some questions/things to consider. Let me know what you think.

Comment thread src/fonts/fallback.ts
return undefined
}

const sizeAdjust = 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hardcoded to 1... what purpose does it serve?

'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
]

function pickUserAgent(): string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we randomizing the user agent?

Comment thread src/fonts/dev-server.ts
}

const mime = FORMAT_MIME[entry.format] ?? 'application/octet-stream'
const data = fs.readFileSync(entry.source)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This blocks the event loop for every font request during development. For small woff2 files it's fine, but for OTF/TTF files that can be several MB, it'll cause noticeable lag. Should be fs.createReadStream(entry.source).pipe(res) or at minimum fs.readFile with a callback.

Comment thread src/fonts/dev-server.ts

res.setHeader('Content-Type', mime)
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For dev shouldn't this be no-cache or a very short max-age?

Comment thread src/fonts/plugin.ts
bunny: 'https://fonts.bunny.net/css2',
}

const activeManifestPaths = new Set<string>()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is at the module level, if someone is running multiple Vite instances this will get muddy. Claude suggests something like the following to fix:

// Delete these two module-level lines:
// const activeManifestPaths = new Set<string>()
// let cleanupBound = false

// Then in configureServer, replace the activeManifestPaths/cleanupBound block with:
server.httpServer?.on('close', () => {
    try {
        if (fs.existsSync(hotManifestPath)) {
            fs.rmSync(hotManifestPath)
        }
    } catch {
        // Best-effort cleanup
    }
})

Comment thread src/fonts/plugin.ts
async buildStart() {
if (resolvedConfig.command !== 'build') {
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we building the CSS twice, once with placeholders, once with the real content? Can we just do it once in generateBundle?

Comment thread src/fonts/css.ts
for (const file of rangedFiles) {
const fileSrc = `url("${filePathMap.get(file.source) ?? file.source}") format("${file.format}")`

rules.push([
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: The output Vite's job? It might be, but it also might be the Blade directive's job? Maybe the plugin just stores the object and the PHP side consumes it and creates the output? Food for thought.

Comment thread src/fonts/css.ts
}

export function generateFallbackFontFace(
family: string,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants