diff --git a/packages/fontaine/README.md b/packages/fontaine/README.md index 8eae0c0a..de506ff7 100644 --- a/packages/fontaine/README.md +++ b/packages/fontaine/README.md @@ -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: diff --git a/packages/fontaine/src/css.ts b/packages/fontaine/src/css.ts index 374fe760..cc1d591b 100644 --- a/packages/fontaine/src/css.ts +++ b/packages/fontaine/src/css.ts @@ -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. diff --git a/packages/fontaine/src/fallbacks.ts b/packages/fontaine/src/fallbacks.ts new file mode 100644 index 00000000..01893508 --- /dev/null +++ b/packages/fontaine/src/fallbacks.ts @@ -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 = { + '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 + /** Font metrics containing category information */ + metrics?: { category?: string } | null + /** User-provided category fallback overrides */ + categoryFallbacks?: Partial> +} + +/** + * 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'] +} diff --git a/packages/fontaine/src/index.ts b/packages/fontaine/src/index.ts index 131a80ec..c31a2ad9 100644 --- a/packages/fontaine/src/index.ts +++ b/packages/fontaine/src/index.ts @@ -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' diff --git a/packages/fontaine/src/metrics.ts b/packages/fontaine/src/metrics.ts index c3bc36ae..6662c490 100644 --- a/packages/fontaine/src/metrics.ts +++ b/packages/fontaine/src/metrics.ts @@ -9,13 +9,16 @@ import { withoutQuotes } from './css' const metricCache: Record = {} -function filterRequiredMetrics({ ascent, descent, lineGap, unitsPerEm, xWidthAvg }: Pick) { +type RequiredFontMetrics = Pick & { 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, } } diff --git a/packages/fontaine/src/transform.ts b/packages/fontaine/src/transform.ts index 14d9bd72..19475f20 100644 --- a/packages/fontaine/src/transform.ts +++ b/packages/fontaine/src/transform.ts @@ -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 { @@ -28,6 +30,14 @@ export interface FontaineTransformOptions { */ fallbacks: string[] | Record + /** + * 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> + /** * Function to resolve a given path to a valid URL or local path. * This is typically used to resolve font file paths. @@ -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)) @@ -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--) { diff --git a/packages/fontaine/test/fallbacks.spec.ts b/packages/fontaine/test/fallbacks.spec.ts new file mode 100644 index 00000000..301e0198 --- /dev/null +++ b/packages/fontaine/test/fallbacks.spec.ts @@ -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) + }) + }) +}) diff --git a/packages/fontaine/test/index.spec.ts b/packages/fontaine/test/index.spec.ts index 9ed973b4..1468c371 100644 --- a/packages/fontaine/test/index.spec.ts +++ b/packages/fontaine/test/index.spec.ts @@ -46,6 +46,7 @@ describe('getMetricsForFamily', () => { expect(metrics).toMatchInlineSnapshot(` { "ascent": 1968, + "category": "sans-serif", "descent": -546, "lineGap": 0, "unitsPerEm": 2000, @@ -80,6 +81,7 @@ describe('getMetricsForFamily', () => { expect(metrics).toMatchInlineSnapshot(` { "ascent": 1025, + "category": "monospace", "descent": -275, "lineGap": 0, "unitsPerEm": 1000, @@ -128,6 +130,7 @@ describe('readMetrics', () => { expect(metrics).toMatchInlineSnapshot(` { "ascent": 1050, + "category": undefined, "descent": -350, "lineGap": 100, "unitsPerEm": 1000, @@ -147,6 +150,7 @@ describe('readMetrics', () => { expect(metrics).toMatchInlineSnapshot(` { "ascent": 1050, + "category": undefined, "descent": -350, "lineGap": 100, "unitsPerEm": 1000, diff --git a/packages/fontaine/test/transform.spec.ts b/packages/fontaine/test/transform.spec.ts index a7044ef2..4d0440f2 100644 --- a/packages/fontaine/test/transform.spec.ts +++ b/packages/fontaine/test/transform.spec.ts @@ -217,7 +217,7 @@ describe('fontaine transform', () => { `) }) - it('should handle font families not specified in fallbacks object', async () => { + it('should handle font families not specified in fallbacks object by using category defaults', async () => { // Use a mock to ensure fromFile returns metrics for our test font // @ts-expect-error not typed as mock fromFile.mockResolvedValueOnce({ @@ -227,6 +227,8 @@ describe('fontaine transform', () => { descent: 200, lineGap: 0, unitsPerEm: 1000, + xWidthAvg: 500, + category: 'sans-serif', }) const result = await transform(` @@ -242,7 +244,308 @@ describe('fontaine transform', () => { resolvePath: id => new URL(id, import.meta.url), }) - expect(result).toBeUndefined() + expect(result).toContain('@font-face') + expect(result).toContain('UnknownFont fallback') + }) + + describe('category-aware fallbacks', () => { + it('should use serif preset for serif fonts', async () => { + expect(await transform(` + @font-face { + font-family: Lora; + src: url('lora.ttf'); + } + `, { + fallbacks: {}, + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "Lora fallback"; + src: local("Noto Serif"); + size-adjust: 97.2973%; + ascent-override: 103.3944%; + descent-override: 28.1611%; + line-gap-override: 0%; + } + @font-face { + font-family: "Lora fallback"; + src: local("Georgia"); + size-adjust: 104.9796%; + ascent-override: 95.8281%; + descent-override: 26.1003%; + line-gap-override: 0%; + } + @font-face { + font-family: "Lora fallback"; + src: local("Times New Roman"); + size-adjust: 115.2%; + ascent-override: 87.3264%; + descent-override: 23.7847%; + line-gap-override: 0%; + } + @font-face { + font-family: Lora; + src: url('lora.ttf'); + }" + `) + }) + + it('should use sans-serif preset for sans-serif fonts', async () => { + expect(await transform(` + @font-face { + font-family: Poppins; + src: url('poppins.ttf'); + } + `, { + fallbacks: {}, + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "Poppins fallback"; + src: local("Noto Sans"); + size-adjust: 105.4852%; + ascent-override: 99.54%; + descent-override: 33.18%; + line-gap-override: 9.48%; + } + @font-face { + font-family: "Poppins fallback"; + src: local("Arial"); + size-adjust: 112.1577%; + ascent-override: 93.6182%; + descent-override: 31.2061%; + line-gap-override: 8.916%; + } + @font-face { + font-family: "Poppins fallback"; + src: local("Helvetica Neue"); + size-adjust: 111.1111%; + ascent-override: 94.5%; + descent-override: 31.5%; + line-gap-override: 9%; + } + @font-face { + font-family: "Poppins fallback"; + src: local("Segoe UI"); + size-adjust: 112.7753%; + ascent-override: 93.1055%; + descent-override: 31.0352%; + line-gap-override: 8.8672%; + } + @font-face { + font-family: "Poppins fallback"; + src: local("BlinkMacSystemFont"); + size-adjust: 120.0469%; + ascent-override: 87.4658%; + descent-override: 29.1553%; + line-gap-override: 8.3301%; + } + @font-face { + font-family: Poppins; + src: url('poppins.ttf'); + }" + `) + }) + + it('should use monospace preset for monospace fonts', async () => { + expect(await transform(` + @font-face { + font-family: 'JetBrains Mono'; + src: url('jetbrains-mono.ttf'); + } + `, { + fallbacks: {}, + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "JetBrains Mono fallback"; + src: local("Noto Sans Mono"); + size-adjust: 100%; + ascent-override: 102%; + descent-override: 30%; + line-gap-override: 0%; + } + @font-face { + font-family: "JetBrains Mono fallback"; + src: local("Roboto Mono"); + size-adjust: 99.9837%; + ascent-override: 102.0166%; + descent-override: 30.0049%; + line-gap-override: 0%; + } + @font-face { + font-family: "JetBrains Mono fallback"; + src: local("Courier New"); + size-adjust: 99.9837%; + ascent-override: 102.0166%; + descent-override: 30.0049%; + line-gap-override: 0%; + } + @font-face { + font-family: 'JetBrains Mono'; + src: url('jetbrains-mono.ttf'); + }" + `) + }) + + it('should allow custom category fallback overrides', async () => { + expect(await transform(` + @font-face { + font-family: Lora; + src: url('lora.ttf'); + } + `, { + fallbacks: {}, + categoryFallbacks: { + serif: ['Georgia'], + }, + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "Lora fallback"; + src: local("Georgia"); + size-adjust: 104.9796%; + ascent-override: 95.8281%; + descent-override: 26.1003%; + line-gap-override: 0%; + } + @font-face { + font-family: Lora; + src: url('lora.ttf'); + }" + `) + }) + + it('should prioritize per-family overrides over category fallbacks', async () => { + expect(await transform(` + @font-face { + font-family: Lora; + src: url('lora.ttf'); + } + `, { + fallbacks: { + Lora: ['Arial'], + }, + categoryFallbacks: { + serif: ['Georgia'], + }, + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "Lora fallback"; + src: local("Arial"); + size-adjust: 104.9796%; + ascent-override: 95.8281%; + descent-override: 26.1003%; + line-gap-override: 0%; + } + @font-face { + font-family: Lora; + src: url('lora.ttf'); + }" + `) + }) + + it('should fall back to sans-serif preset when font has no category', async () => { + // @ts-expect-error not typed as mock + fromFile.mockResolvedValueOnce({ + familyName: 'UnknownFont', + capHeight: 1000, + ascent: 1000, + descent: 200, + lineGap: 0, + unitsPerEm: 1000, + xWidthAvg: 500, + // No category field + }) + + expect(await transform(` + @font-face { + font-family: UnknownFont; + src: url('unknownfont.ttf'); + } + `, { + fallbacks: {}, + resolvePath: id => new URL(id, import.meta.url), + })) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "UnknownFont fallback"; + src: local("Noto Sans"); + size-adjust: 105.4852%; + ascent-override: 94.8%; + descent-override: 18.96%; + line-gap-override: 0%; + } + @font-face { + font-family: "UnknownFont fallback"; + src: local("Arial"); + size-adjust: 112.1577%; + ascent-override: 89.1602%; + descent-override: 17.832%; + line-gap-override: 0%; + } + @font-face { + font-family: "UnknownFont fallback"; + src: local("Helvetica Neue"); + size-adjust: 111.1111%; + ascent-override: 90%; + descent-override: 18%; + line-gap-override: 0%; + } + @font-face { + font-family: "UnknownFont fallback"; + src: local("Segoe UI"); + size-adjust: 112.7753%; + ascent-override: 88.6719%; + descent-override: 17.7344%; + line-gap-override: 0%; + } + @font-face { + font-family: "UnknownFont fallback"; + src: local("BlinkMacSystemFont"); + size-adjust: 120.0469%; + ascent-override: 83.3008%; + descent-override: 16.6602%; + line-gap-override: 0%; + } + @font-face { + font-family: UnknownFont; + src: url('unknownfont.ttf'); + }" + `) + }) + + it('should respect legacy global fallbacks array', async () => { + expect(await transform(` + @font-face { + font-family: Lora; + src: url('lora.ttf'); + } + `)) + .toMatchInlineSnapshot(` + "@font-face { + font-family: "Lora fallback"; + src: local("Segoe UI"); + size-adjust: 105.5577%; + ascent-override: 95.3033%; + descent-override: 25.9574%; + line-gap-override: 0%; + } + @font-face { + font-family: "Lora fallback"; + src: local("Arial"); + size-adjust: 104.9796%; + ascent-override: 95.8281%; + descent-override: 26.1003%; + line-gap-override: 0%; + } + @font-face { + font-family: Lora; + src: url('lora.ttf'); + }" + `) + }) }) }) diff --git a/packages/fontless/README.md b/packages/fontless/README.md index a4fedd6f..5f17cc30 100644 --- a/packages/fontless/README.md +++ b/packages/fontless/README.md @@ -82,8 +82,11 @@ fontless({ preload: true, weights: [400, 700], styles: ['normal', 'italic'], + // Fallbacks use category-aware presets from fontaine + // Override specific generic families as needed fallbacks: { - 'sans-serif': ['Arial', 'Helvetica Neue'] + 'sans-serif': ['Arial', 'Helvetica Neue'], + // serif, monospace, cursive, fantasy, system-ui, etc. use shared defaults } }, @@ -115,6 +118,19 @@ fontless({ }) ``` +### Category-Aware Fallbacks + +Fontless uses category-aware fallback presets shared with the [fontaine](https://github.com/unjs/fontaine) package. These presets provide optimized system fonts for different generic font families: + +- **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` +- **cursive**: Uses handwriting category fallbacks +- **fantasy**: Uses display category fallbacks +- **system-ui**, **ui-serif**, **ui-sans-serif**, **ui-monospace**: Mapped to corresponding category presets + +You can override fallbacks for specific generic families in the `defaults.fallbacks` configuration while keeping the shared defaults for others. This ensures consistent font fallback behavior across your application and reduces cumulative layout shift (CLS). + ## How It Works Fontless works by: diff --git a/packages/fontless/src/defaults.ts b/packages/fontless/src/defaults.ts index 5a850f6f..022e75ee 100644 --- a/packages/fontless/src/defaults.ts +++ b/packages/fontless/src/defaults.ts @@ -1,5 +1,6 @@ import type { FontlessOptions } from './types' +import { DEFAULT_CATEGORY_FALLBACKS } from 'fontaine' import { providers } from 'unifont' export const defaultValues = { @@ -15,21 +16,15 @@ export const defaultValues = { 'latin', ], fallbacks: { - 'serif': ['Times New Roman'], - 'sans-serif': ['Arial'], - 'monospace': ['Courier New'], - 'cursive': [], - 'fantasy': [], - 'system-ui': [ - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - ], - 'ui-serif': ['Times New Roman'], - 'ui-sans-serif': ['Arial'], - 'ui-monospace': ['Courier New'], + 'serif': DEFAULT_CATEGORY_FALLBACKS.serif, + 'sans-serif': DEFAULT_CATEGORY_FALLBACKS['sans-serif'], + 'monospace': DEFAULT_CATEGORY_FALLBACKS.monospace, + 'cursive': DEFAULT_CATEGORY_FALLBACKS.handwriting, + 'fantasy': DEFAULT_CATEGORY_FALLBACKS.display, + 'system-ui': DEFAULT_CATEGORY_FALLBACKS['sans-serif'], + 'ui-serif': DEFAULT_CATEGORY_FALLBACKS.serif, + 'ui-sans-serif': DEFAULT_CATEGORY_FALLBACKS['sans-serif'], + 'ui-monospace': DEFAULT_CATEGORY_FALLBACKS.monospace, 'ui-rounded': [], 'emoji': [], 'math': [], diff --git a/packages/fontless/src/resolve.ts b/packages/fontless/src/resolve.ts index 0ade8cca..957751ef 100644 --- a/packages/fontless/src/resolve.ts +++ b/packages/fontless/src/resolve.ts @@ -51,6 +51,7 @@ export async function createResolver(context: ResolverContext): Promise String(v)))], styles: [...new Set(options.defaults?.styles || defaultValues.styles)], diff --git a/packages/fontless/test/defaults.spec.ts b/packages/fontless/test/defaults.spec.ts new file mode 100644 index 00000000..1f50b244 --- /dev/null +++ b/packages/fontless/test/defaults.spec.ts @@ -0,0 +1,66 @@ +import { DEFAULT_CATEGORY_FALLBACKS } from 'fontaine' +import { describe, expect, it } from 'vitest' +import { defaultValues } from '../src/defaults' + +describe('fontless defaults', () => { + describe('fallbacks', () => { + it('should use shared category-aware presets from fontaine', () => { + expect(defaultValues.fallbacks.serif).toEqual(DEFAULT_CATEGORY_FALLBACKS.serif) + expect(defaultValues.fallbacks['sans-serif']).toEqual(DEFAULT_CATEGORY_FALLBACKS['sans-serif']) + expect(defaultValues.fallbacks.monospace).toEqual(DEFAULT_CATEGORY_FALLBACKS.monospace) + }) + + it('should map generic families to category presets', () => { + // Core generic families should use category presets + expect(defaultValues.fallbacks.serif).toEqual(['Times New Roman', 'Georgia', 'Noto Serif']) + expect(defaultValues.fallbacks['sans-serif']).toEqual(['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans']) + expect(defaultValues.fallbacks.monospace).toEqual(['Courier New', 'Roboto Mono', 'Noto Sans Mono']) + }) + + it('should map ui- families to appropriate category presets', () => { + expect(defaultValues.fallbacks['ui-serif']).toEqual(DEFAULT_CATEGORY_FALLBACKS.serif) + expect(defaultValues.fallbacks['ui-sans-serif']).toEqual(DEFAULT_CATEGORY_FALLBACKS['sans-serif']) + expect(defaultValues.fallbacks['ui-monospace']).toEqual(DEFAULT_CATEGORY_FALLBACKS.monospace) + }) + + it('should map cursive to handwriting preset', () => { + expect(defaultValues.fallbacks.cursive).toEqual(DEFAULT_CATEGORY_FALLBACKS.handwriting) + }) + + it('should map fantasy to display preset', () => { + expect(defaultValues.fallbacks.fantasy).toEqual(DEFAULT_CATEGORY_FALLBACKS.display) + }) + + it('should map system-ui to sans-serif preset', () => { + expect(defaultValues.fallbacks['system-ui']).toEqual(DEFAULT_CATEGORY_FALLBACKS['sans-serif']) + }) + + it('should have empty arrays for specialized families', () => { + expect(defaultValues.fallbacks['ui-rounded']).toEqual([]) + expect(defaultValues.fallbacks.emoji).toEqual([]) + expect(defaultValues.fallbacks.math).toEqual([]) + expect(defaultValues.fallbacks.fangsong).toEqual([]) + }) + }) + + describe('weights', () => { + it('should default to [400]', () => { + expect(defaultValues.weights).toEqual([400]) + }) + }) + + describe('styles', () => { + it('should default to normal and italic', () => { + expect(defaultValues.styles).toEqual(['normal', 'italic']) + }) + }) + + describe('subsets', () => { + it('should include common unicode subsets', () => { + expect(defaultValues.subsets).toContain('latin') + expect(defaultValues.subsets).toContain('latin-ext') + expect(defaultValues.subsets).toContain('cyrillic') + expect(defaultValues.subsets).toContain('greek') + }) + }) +}) diff --git a/packages/fontless/test/e2e.spec.ts b/packages/fontless/test/e2e.spec.ts index 5c7ec3fe..7e3f9bb2 100644 --- a/packages/fontless/test/e2e.spec.ts +++ b/packages/fontless/test/e2e.spec.ts @@ -46,12 +46,12 @@ describe.each(fixtures)('e2e %s', (fixture) => { const content = await readFile(join(outputDir!, css), 'utf-8') expect(content).toContain('url(/assets/_fonts') if (fixture === 'vanilla-app') { - expect(content).toContain('--font-test-variable: "Press Start 2P", "Press Start 2P Fallback: Arial", sans-serif') + expect(content).toContain('--font-test-variable: "Press Start 2P", "Press Start 2P Fallback: BlinkMacSystemFont", "Press Start 2P Fallback: Segoe UI", "Press Start 2P Fallback: Helvetica Neue", "Press Start 2P Fallback: Arial", "Press Start 2P Fallback: Noto Sans", sans-serif') const html = files.find(file => file.endsWith('.html'))! expect(await readFile(join(outputDir!, html), 'utf-8')).toContain('rel="preload"') } if (fixture === 'tailwind') { - expect(content).toContain('--font-sans:"Geist", "Geist Fallback: Arial",sans-serif') + expect(content).toContain('--font-sans:"Geist", "Geist Fallback: BlinkMacSystemFont", "Geist Fallback: Segoe UI", "Geist Fallback: Helvetica Neue", "Geist Fallback: Arial", "Geist Fallback: Noto Sans",sans-serif') const woff = content.indexOf('format(woff)') const woff2 = content.indexOf('format(woff2)') expect(woff >= 0 && woff2 >= 0).toBe(true)