Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/fontaine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,57 @@ If your custom font is used through the mechanism of CSS variables, you'll need

Behind the scenes, there is a 'Poppins fallback' `@font-face` rule that has been created by fontaine. By manually adding this fallback font family to our CSS variable, we make our site use the fallback `@font-face` rule with the correct font metrics that fontaine generates.

## Category-Aware Fallbacks

Fontaine automatically selects appropriate fallback fonts based on font categories (serif, sans-serif, monospace, etc.) when using object-based fallback configuration.

```js
const options = {
// Use an empty object to enable automatic category-based fallbacks
fallbacks: {},

// Or customize specific categories while keeping defaults for others
categoryFallbacks: {
'serif': ['Georgia', 'Times New Roman'],
'sans-serif': ['Arial', 'Helvetica'],
// monospace, display, and handwriting categories use defaults
}
}
```

### Default Category Fallbacks

- **sans-serif**: `BlinkMacSystemFont`, `Segoe UI`, `Helvetica Neue`, `Arial`, `Noto Sans`
- **serif**: `Times New Roman`, `Georgia`, `Noto Serif`
- **monospace**: `Courier New`, `Roboto Mono`, `Noto Sans Mono`
- **display** & **handwriting**: Same as sans-serif

> **Note:** These presets are available programmatically via `DEFAULT_CATEGORY_FALLBACKS` and can be used with the `resolveCategoryFallbacks` helper function for advanced use cases. Both are exported from the `fontaine` package and shared across related packages (e.g., `fontless`) to ensure consistent fallback behavior.

### Fallback Priority

1. **Array format** (`fallbacks: ['Arial']`) - Uses specified fonts for all families (legacy behavior)
2. **Per-family override** (`fallbacks: { Poppins: ['Arial'] }`) - Uses specified fonts for that family
3. **Category-based** - When a family isn't specified, uses the appropriate category preset
4. **Global default** - Falls back to sans-serif preset if no category is detected

Example:

```js
{
fallbacks: {
// Specific override for Poppins
'Poppins': ['Arial'],
// Other sans-serif fonts will use the sans-serif preset
// Serif fonts will use the serif preset automatically
},
categoryFallbacks: {
// Customize the serif preset
'serif': ['Georgia']
}
}
```

## How it works

`fontaine` will scan your `@font-face` rules and generate fallback rules with the correct metrics. For example:
Expand Down
4 changes: 3 additions & 1 deletion packages/fontaine/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ interface FallbackOptions {
export type FontFaceMetrics = Pick<
Font,
'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
>
> & {
category?: string
}

/**
* Generates a CSS `@font-face' declaration for a font, taking fallback and resizing into account.
Expand Down
74 changes: 74 additions & 0 deletions packages/fontaine/src/fallbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type FontCategory = 'sans-serif' | 'serif' | 'monospace' | 'display' | 'handwriting'

/**
* Default fallback font stacks for each font category.
* These are system fonts that work across different platforms.
*/
export const DEFAULT_CATEGORY_FALLBACKS: Record<FontCategory, string[]> = {
'sans-serif': ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],
'serif': ['Times New Roman', 'Georgia', 'Noto Serif'],
'monospace': ['Courier New', 'Roboto Mono', 'Noto Sans Mono'],
'display': ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],
'handwriting': ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],
}

export interface ResolveCategoryFallbacksOptions {
/** Font family name to resolve fallbacks for */
fontFamily: string
/** Global fallbacks (array) or per-family fallbacks (object). Array overrides all category-based resolution. */
fallbacks: string[] | Record<string, string[]>
/** Font metrics containing category information */
metrics?: { category?: string } | null
/** User-provided category fallback overrides */
categoryFallbacks?: Partial<Record<FontCategory, string[]>>
}

/**
* Resolves the appropriate fallback fonts for a given font family.
*
* Resolution order:
* 1. If fallbacks is an array, use it as a global override
* 2. If fallbacks is an object with the font family key, use that override
* 3. If metrics contain a category, use the category-based fallbacks
* 4. Default to sans-serif category fallbacks
*
* @param options - Configuration for fallback resolution
* @returns Array of fallback font family names
*/
export function resolveCategoryFallbacks(options: ResolveCategoryFallbacksOptions): string[] {
const { fontFamily, fallbacks, metrics, categoryFallbacks } = options

// Merge user-provided category fallbacks with defaults
const mergedCategoryFallbacks = { ...DEFAULT_CATEGORY_FALLBACKS }
if (categoryFallbacks) {
for (const category in categoryFallbacks) {
const categoryKey = category as FontCategory
const categoryFallbacksList = categoryFallbacks[categoryKey]
if (categoryFallbacksList) {
mergedCategoryFallbacks[categoryKey] = categoryFallbacksList
}
}
}

// 1. If fallbacks is an array, use it as a global override (legacy behavior)
if (Array.isArray(fallbacks)) {
return fallbacks
}

// 2. Return explicit per-family overrides when supplied (object notation)
const familyFallback = fallbacks[fontFamily]
if (familyFallback) {
return familyFallback
}

// 3. If metrics have a category, return the merged preset for that category
if (metrics?.category) {
const categoryFallback = mergedCategoryFallbacks[metrics.category as FontCategory]
if (categoryFallback) {
return categoryFallback
}
}

// 4. Fallback to sans-serif preset
return mergedCategoryFallbacks['sans-serif']
}
4 changes: 3 additions & 1 deletion packages/fontaine/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { generateFallbackName, generateFontFace } from './css'
export { DEFAULT_CATEGORY_FALLBACKS, type FontCategory, resolveCategoryFallbacks } from './fallbacks'
export type { ResolveCategoryFallbacksOptions } from './fallbacks'
export { getMetricsForFamily, readMetrics } from './metrics'
export { FontaineTransform } from './transform'

export { FontaineTransform } from './transform'
export type { FontaineTransformOptions } from './transform'
15 changes: 9 additions & 6 deletions packages/fontaine/src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import { withoutQuotes } from './css'

const metricCache: Record<string, FontFaceMetrics | null> = {}

function filterRequiredMetrics({ ascent, descent, lineGap, unitsPerEm, xWidthAvg }: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'>) {
type RequiredFontMetrics = Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'> & { category?: string }

function filterRequiredMetrics(font: RequiredFontMetrics): FontFaceMetrics {
return {
ascent,
descent,
lineGap,
unitsPerEm,
xWidthAvg,
ascent: font.ascent,
descent: font.descent,
lineGap: font.lineGap,
unitsPerEm: font.unitsPerEm,
xWidthAvg: font.xWidthAvg,
category: font.category,
}
}

Expand Down
26 changes: 17 additions & 9 deletions packages/fontaine/src/transform.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { FontCategory } from './fallbacks'
import { pathToFileURL } from 'node:url'
import { parse, walk } from 'css-tree'
import { anyOf, char, createRegExp, exactly, oneOrMore } from 'magic-regexp'
import MagicString from 'magic-string'
import { isAbsolute } from 'pathe'

import { isAbsolute } from 'pathe'
import { createUnplugin } from 'unplugin'
import { generateFallbackName, generateFontFace, parseFontFace, withoutQuotes } from './css'
import { resolveCategoryFallbacks } from './fallbacks'
import { getMetricsForFamily, readMetrics } from './metrics'

export interface FontaineTransformOptions {
Expand All @@ -28,6 +30,14 @@ export interface FontaineTransformOptions {
*/
fallbacks: string[] | Record<string, string[]>

/**
* Category-specific fallback font stacks.
* When a font's category is detected (serif, sans-serif, monospace, etc.),
* these fallbacks will be used if no explicit per-family override is provided.
* @optional
*/
categoryFallbacks?: Partial<Record<FontCategory, string[]>>

/**
* Function to resolve a given path to a valid URL or local path.
* This is typically used to resolve font file paths.
Expand Down Expand Up @@ -85,13 +95,6 @@ export const FontaineTransform = createUnplugin((options: FontaineTransformOptio

const skipFontFaceGeneration = options.skipFontFaceGeneration || (() => false)

function getFallbacksForFamily(family: string): string[] {
if (Array.isArray(options.fallbacks)) {
return options.fallbacks
}
return options.fallbacks[family] || []
}

function readMetricsFromId(path: string, importer: string) {
const resolvedPath = isAbsolute(importer) && RELATIVE_RE.test(path)
? new URL(path, pathToFileURL(importer))
Expand Down Expand Up @@ -123,7 +126,12 @@ export const FontaineTransform = createUnplugin((options: FontaineTransformOptio
if (!metrics)
continue

const familyFallbacks = getFallbacksForFamily(family)
const familyFallbacks = resolveCategoryFallbacks({
fontFamily: family,
fallbacks: options.fallbacks,
metrics,
categoryFallbacks: options.categoryFallbacks,
})

// Iterate backwards: Browsers will use the last working font-face in the stylesheet
for (let i = familyFallbacks.length - 1; i >= 0; i--) {
Expand Down
119 changes: 119 additions & 0 deletions packages/fontaine/test/fallbacks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CATEGORY_FALLBACKS, resolveCategoryFallbacks } from '../src/fallbacks'

describe('fallbacks module', () => {
describe('default category fallbacks', () => {
it('should export default category fallbacks', () => {
expect(DEFAULT_CATEGORY_FALLBACKS).toBeDefined()
expect(DEFAULT_CATEGORY_FALLBACKS['sans-serif']).toEqual(['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'])
expect(DEFAULT_CATEGORY_FALLBACKS.serif).toEqual(['Times New Roman', 'Georgia', 'Noto Serif'])
expect(DEFAULT_CATEGORY_FALLBACKS.monospace).toEqual(['Courier New', 'Roboto Mono', 'Noto Sans Mono'])
expect(DEFAULT_CATEGORY_FALLBACKS.display).toEqual(['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'])
expect(DEFAULT_CATEGORY_FALLBACKS.handwriting).toEqual(['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'])
})
})

describe('resolveCategoryFallbacks', () => {
it('should return global fallbacks array when fallbacks is an array', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Poppins',
fallbacks: ['Arial', 'Helvetica'],
metrics: { category: 'sans-serif' },
})
expect(result).toEqual(['Arial', 'Helvetica'])
})

it('should return per-family fallbacks when specified in object', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Poppins',
fallbacks: { Poppins: ['Custom Font'], Roboto: ['Another Font'] },
metrics: { category: 'sans-serif' },
})
expect(result).toEqual(['Custom Font'])
})

it('should use category fallbacks from metrics when no explicit override', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Lora',
fallbacks: {},
metrics: { category: 'serif' },
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS.serif)
})

it('should use custom category fallbacks when provided', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Lora',
fallbacks: {},
metrics: { category: 'serif' },
categoryFallbacks: { serif: ['Georgia Only'] },
})
expect(result).toEqual(['Georgia Only'])
})

it('should fall back to sans-serif when no category in metrics', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'UnknownFont',
fallbacks: {},
metrics: {},
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS['sans-serif'])
})

it('should fall back to sans-serif when metrics is null', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'UnknownFont',
fallbacks: {},
metrics: null,
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS['sans-serif'])
})

it('should prioritize per-family overrides over category fallbacks', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Lora',
fallbacks: { Lora: ['Arial'] },
metrics: { category: 'serif' },
categoryFallbacks: { serif: ['Georgia'] },
})
expect(result).toEqual(['Arial'])
})

it('should prioritize global array overrides over everything', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Lora',
fallbacks: ['Helvetica'],
metrics: { category: 'serif' },
categoryFallbacks: { serif: ['Georgia'] },
})
expect(result).toEqual(['Helvetica'])
})

it('should handle monospace category', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'JetBrains Mono',
fallbacks: {},
metrics: { category: 'monospace' },
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS.monospace)
})

it('should handle display category', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Bebas Neue',
fallbacks: {},
metrics: { category: 'display' },
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS.display)
})

it('should handle handwriting category', () => {
const result = resolveCategoryFallbacks({
fontFamily: 'Dancing Script',
fallbacks: {},
metrics: { category: 'handwriting' },
})
expect(result).toEqual(DEFAULT_CATEGORY_FALLBACKS.handwriting)
})
})
})
4 changes: 4 additions & 0 deletions packages/fontaine/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('getMetricsForFamily', () => {
expect(metrics).toMatchInlineSnapshot(`
{
"ascent": 1968,
"category": "sans-serif",
"descent": -546,
"lineGap": 0,
"unitsPerEm": 2000,
Expand Down Expand Up @@ -80,6 +81,7 @@ describe('getMetricsForFamily', () => {
expect(metrics).toMatchInlineSnapshot(`
{
"ascent": 1025,
"category": "monospace",
"descent": -275,
"lineGap": 0,
"unitsPerEm": 1000,
Expand Down Expand Up @@ -128,6 +130,7 @@ describe('readMetrics', () => {
expect(metrics).toMatchInlineSnapshot(`
{
"ascent": 1050,
"category": undefined,
"descent": -350,
"lineGap": 100,
"unitsPerEm": 1000,
Expand All @@ -147,6 +150,7 @@ describe('readMetrics', () => {
expect(metrics).toMatchInlineSnapshot(`
{
"ascent": 1050,
"category": undefined,
"descent": -350,
"lineGap": 100,
"unitsPerEm": 1000,
Expand Down
Loading
Loading