Skip to content
Merged
Show file tree
Hide file tree
Changes from 71 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
71f7820
feat(fonts): generate fallbacks for all faces
florian-lefebvre Apr 16, 2025
5d89da8
fix: avoid duplicates
florian-lefebvre Apr 16, 2025
1feaf67
feat: experiment
florian-lefebvre Apr 17, 2025
4d68367
feat: experiment
florian-lefebvre Apr 17, 2025
58aa0c1
feat: experiment
florian-lefebvre Apr 17, 2025
693a2b6
feat: experiment
florian-lefebvre Apr 17, 2025
1945164
feat: experiment
florian-lefebvre Apr 17, 2025
35d670c
feat: experiment
florian-lefebvre Apr 17, 2025
3d42f0e
feat: experiment
florian-lefebvre Apr 17, 2025
d7a8b46
feat: experiment
florian-lefebvre Apr 17, 2025
b6cfd54
feat: experiment
florian-lefebvre Apr 17, 2025
ea56a48
feat: experiment
florian-lefebvre Apr 17, 2025
caf9539
feat: experiment
florian-lefebvre Apr 17, 2025
ba76199
feat: experiment
florian-lefebvre Apr 17, 2025
d6af004
feat: experiment
florian-lefebvre Apr 17, 2025
d2d573a
feat: experiment
florian-lefebvre Apr 18, 2025
5151bc2
feat: experiment
florian-lefebvre Apr 18, 2025
c240c7d
feat: experiment
florian-lefebvre Apr 18, 2025
00e85ca
Merge branch 'main' into feat/fonts-fallbacks-for-all-faces
florian-lefebvre Apr 18, 2025
2ab9768
Merge branch 'feat/fonts-fallbacks-for-all-faces' into feat/fonts-ref…
florian-lefebvre Apr 18, 2025
4e8b2c0
feat: experiment
florian-lefebvre Apr 18, 2025
cc58199
feat: experiment
florian-lefebvre Apr 18, 2025
265e768
Merge branch 'main' into feat/fonts-refactor
florian-lefebvre Apr 18, 2025
fd4faf4
feat: experiment
florian-lefebvre Apr 18, 2025
2ab5ce2
feat: experiment
florian-lefebvre Apr 18, 2025
8f88ca0
feat: experiment
florian-lefebvre Apr 18, 2025
77140e6
feat: experiment
florian-lefebvre Apr 18, 2025
ffed015
feat: experiment
florian-lefebvre Apr 18, 2025
01191d0
feat: experiment
florian-lefebvre Apr 18, 2025
2afd050
feat: experiment
florian-lefebvre Apr 18, 2025
a103ad9
feat: experiment
florian-lefebvre Apr 18, 2025
a6e7155
feat: experiment
florian-lefebvre Apr 18, 2025
b5476ba
feat: experiment
florian-lefebvre Apr 18, 2025
6822ef7
feat: experiment
florian-lefebvre Apr 18, 2025
1e1ac84
feat: deep sort
florian-lefebvre Apr 18, 2025
6c2b4ea
feat: move stuff around
florian-lefebvre Apr 18, 2025
6ce6f3c
feat: handle space in family name
florian-lefebvre Apr 18, 2025
001fc6a
feat: jsdocs
florian-lefebvre Apr 18, 2025
8b437db
feat: move type
florian-lefebvre Apr 18, 2025
4268776
chore: comments
florian-lefebvre Apr 18, 2025
0a74196
feat: simplify
florian-lefebvre Apr 18, 2025
f18a270
Merge branch 'main' into feat/fonts-refactor
florian-lefebvre Apr 18, 2025
42fe62b
Merge branch 'main' into feat/fonts-refactor
florian-lefebvre Apr 24, 2025
edeb949
feat: refactor
florian-lefebvre Apr 24, 2025
07e0e65
feat: refactor create url proxy
florian-lefebvre Apr 24, 2025
5c146ae
feat: improve type
florian-lefebvre Apr 24, 2025
51d59ca
feat: use functions instead of classes
florian-lefebvre Apr 24, 2025
837cd94
chore: comment
florian-lefebvre Apr 24, 2025
c6e22de
feat: refactor
florian-lefebvre Apr 24, 2025
2f87326
chore: clean
florian-lefebvre Apr 24, 2025
3d92628
feat: refactor
florian-lefebvre Apr 24, 2025
957215c
chore: comment
florian-lefebvre Apr 24, 2025
3da9498
feat: work on test
florian-lefebvre Apr 24, 2025
bd10d34
feat: work on test
florian-lefebvre Apr 24, 2025
ed2c282
feat: work on test
florian-lefebvre Apr 24, 2025
9517bc3
feat: work on test
florian-lefebvre Apr 24, 2025
3b2af11
feat: work on test
florian-lefebvre Apr 24, 2025
fb84109
feat: work on test
florian-lefebvre Apr 24, 2025
30c528f
feat: work on test
florian-lefebvre Apr 24, 2025
9cf8025
feat: work on test
florian-lefebvre Apr 24, 2025
ab2a1ee
feat: work on test
florian-lefebvre Apr 24, 2025
3e9a7e1
feat: work on test
florian-lefebvre Apr 24, 2025
6bbf14b
feat: work on test
florian-lefebvre Apr 24, 2025
6f60e6d
feat: work on test
florian-lefebvre Apr 25, 2025
c461fad
feat: work on test
florian-lefebvre Apr 25, 2025
2ce8b6b
feat: work on test
florian-lefebvre Apr 25, 2025
d7e89f6
feat: optimize fonts
florian-lefebvre Apr 25, 2025
54fdac5
Merge branch 'main' into feat/fonts-refactor
florian-lefebvre Apr 25, 2025
895c58a
feat: public warn
florian-lefebvre Apr 25, 2025
518ae7c
chore: changeset
florian-lefebvre Apr 25, 2025
c864483
fix: test
florian-lefebvre Apr 25, 2025
06e974b
Merge branch 'main' into feat/fonts-refactor
florian-lefebvre Apr 28, 2025
7e6a9d1
feedback
florian-lefebvre Apr 28, 2025
bcbfb77
fix: test
florian-lefebvre Apr 28, 2025
41b8c37
Discard changes to packages/astro/test/fixtures/fonts/astro.config.mjs
florian-lefebvre Apr 28, 2025
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
5 changes: 5 additions & 0 deletions .changeset/slick-garlics-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Reduces the amount of preloaded files for the local provider when using the experimental fonts API
5 changes: 5 additions & 0 deletions .changeset/sour-dryers-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a case where invalid CSS was emitted when using an experimental fonts API family name containing a space
5 changes: 2 additions & 3 deletions packages/astro/src/assets/fonts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const fallbacksSchema = z.object({
* ```
*

* If the last font in the `fallbacks` array is a [generic family name](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#generic-name), an [optimized fallback](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false.
* If the last font in the `fallbacks` array is a [generic family name](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#generic-name), Astro will attempt to generate [optimized fallbacks](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false.
*/
fallbacks: z.array(z.string()).nonempty().optional(),
/**
Expand Down Expand Up @@ -162,11 +162,10 @@ export const remoteFontFamilySchema = requiredFamilyAttributesSchema
* An array of [font styles](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style).
*/
styles: z.array(styleSchema).nonempty().optional(),
// TODO: better link
/**
* @default `["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]`
*
* An array of [font subsets](https://fonts.google.com/knowledge/glossary/subsetting):
* An array of [font subsets](https://knaap.dev/posts/font-subsetting/):
*/
subsets: z.array(z.string()).nonempty().optional(),
}),
Expand Down
91 changes: 18 additions & 73 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { FontFaceMetrics } from './metrics.js';
import type { ResolvedRemoteFontFamily } from './types.js';
import type { Defaults } from "./types.js";

export const LOCAL_PROVIDER_NAME = 'local';

export const DEFAULTS = {
export const DEFAULTS: Defaults = {
weights: ['400'],
styles: ['normal', 'italic'],
subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'],
// Technically serif is the browser default but most websites these days use sans-serif
fallbacks: ['sans-serif'],
optimizedFallbacks: true,
} satisfies Partial<ResolvedRemoteFontFamily>;
};

export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
Expand All @@ -28,74 +27,20 @@ export const FONT_FORMAT_MAP: Record<(typeof FONT_TYPES)[number], string> = {
eot: 'embedded-opentype',
};

// Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json
export const SYSTEM_METRICS = {
'Times New Roman': {
ascent: 1825,
descent: -443,
lineGap: 87,
unitsPerEm: 2048,
xWidthAvg: 832,
},
Arial: {
ascent: 1854,
descent: -434,
lineGap: 67,
unitsPerEm: 2048,
xWidthAvg: 913,
},
'Courier New': {
ascent: 1705,
descent: -615,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 1229,
},
BlinkMacSystemFont: {
ascent: 1980,
descent: -432,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 853,
},
'Segoe UI': {
ascent: 2210,
descent: -514,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 908,
},
Roboto: {
ascent: 1900,
descent: -500,
lineGap: 0,
unitsPerEm: 2048,
xWidthAvg: 911,
},
'Helvetica Neue': {
ascent: 952,
descent: -213,
lineGap: 28,
unitsPerEm: 1000,
xWidthAvg: 450,
},
} satisfies Record<string, FontFaceMetrics>;

// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75
export const DEFAULT_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'],
'ui-rounded': [],
emoji: [],
math: [],
fangsong: [],
} as const satisfies Record<string, Array<keyof typeof SYSTEM_METRICS>>;
export const GENERIC_FALLBACK_NAMES = [
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'math',
'fangsong',
] as const;

export const FONTS_TYPES_FILE = 'fonts.d.ts';
95 changes: 95 additions & 0 deletions packages/astro/src/assets/fonts/definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import type { AstroFontProvider, FontType, PreloadData, ResolvedFontProvider } from './types.js';
import type * as unifont from 'unifont';
import type { FontFaceMetrics, GenericFallbackName } from './types.js';
import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js';

export interface Hasher {
hashString: (input: string) => string;
hashObject: (input: Record<string, any>) => string;
}

export interface RemoteFontProviderModResolver {
resolve: (id: string) => Promise<any>;
}

export interface RemoteFontProviderResolver {
resolve: (provider: AstroFontProvider) => Promise<ResolvedFontProvider>;
}

export interface LocalProviderUrlResolver {
resolve: (input: string) => string;
}

type SingleErrorInput<TType extends string, TData extends Record<string, any>> = {
type: TType;
data: TData;
cause: unknown;
};

export type ErrorHandlerInput =
| SingleErrorInput<
'cannot-load-font-provider',
{
entrypoint: string;
}
>
| SingleErrorInput<'unknown-fs-error', {}>
| SingleErrorInput<'cannot-fetch-font-file', { url: string }>
| SingleErrorInput<'cannot-extract-font-type', { url: string }>;

export interface ErrorHandler {
handle: (input: ErrorHandlerInput) => Error;
}

export interface UrlProxy {
proxy: (input: {
url: string;
collectPreload: boolean;
data: Partial<unifont.FontFaceData>;
}) => string;
}

export interface UrlProxyContentResolver {
resolve: (url: string) => string;
}

export interface DataCollector {
collect: (input: {
originalUrl: string;
hash: string;
data: Partial<unifont.FontFaceData>;
preload: PreloadData | null;
}) => void;
}

export type CssProperties = Record<string, string | undefined>;

export interface CssRenderer {
generateFontFace: (family: string, properties: CssProperties) => string;
generateCssVariable: (key: string, values: Array<string>) => string;
}

export interface FontMetricsResolver {
getMetrics: (name: string, font: CollectedFontForMetrics) => Promise<FontFaceMetrics>;
generateFontFace: (input: {
metrics: FontFaceMetrics;
fallbackMetrics: FontFaceMetrics;
name: string;
font: string;
properties: CssProperties;
}) => string;
}

export interface SystemFallbacksProvider {
getLocalFonts: (fallback: GenericFallbackName) => Array<string> | null;
getMetricsForLocalFont: (family: string) => FontFaceMetrics;
}

export interface FontFetcher {
fetch: (hash: string, url: string) => Promise<Buffer>;
}

export interface FontTypeExtractor {
extract: (url: string) => FontType;
}
50 changes: 50 additions & 0 deletions packages/astro/src/assets/fonts/implementations/css-renderer.ts
Copy link
Member

Choose a reason for hiding this comment

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

The folders implementations/ and logic/ have an unusual name. We usually name the folders by feature, but in this case, it's hard to understand which "feature" belongs to. Maybe consider a main folder called fonts/ and then have everything there

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { CssProperties, CssRenderer } from '../definitions.js';

export function renderFontFace(properties: CssProperties, minify: boolean): string {
// Line return
const lr = minify ? '' : `\n`;
// Space
const sp = minify ? '' : ' ';

return `@font-face${sp}{${lr}${Object.entries(properties)
.filter(([, value]) => Boolean(value))
.map(([key, value]) => `${sp}${sp}${key}:${sp}${value};`)
.join(lr)}${lr}}${lr}`;
}

export function renderCssVariable(key: string, values: Array<string>, minify: boolean): string {
// Line return
const lr = minify ? '' : `\n`;
// Space
const sp = minify ? '' : ' ';

return `:root${sp}{${lr}${sp}${sp}${key}:${sp}${values.map((v) => handleValueWithSpaces(v)).join(`,${sp}`)};${lr}}${lr}`;
}

export function withFamily(family: string, properties: CssProperties): CssProperties {
return {
'font-family': handleValueWithSpaces(family),
...properties,
};
}

const SPACE_RE = /\s/;

/** If the value contains spaces (which would be incorrectly interpreted), we wrap it in quotes. */
export function handleValueWithSpaces(value: string): string {
if (SPACE_RE.test(value)) {
return JSON.stringify(value);
}
return value;
}

export function createMinifiableCssRenderer({ minify }: { minify: boolean }): CssRenderer {
return {
generateFontFace(family, properties) {
return renderFontFace(withFamily(family, properties), minify);
},
generateCssVariable(key, values) {
return renderCssVariable(key, values, minify);
},
};
}
25 changes: 25 additions & 0 deletions packages/astro/src/assets/fonts/implementations/data-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { DataCollector } from '../definitions.js';
import type { CreateUrlProxyParams } from '../types.js';

export function createDataCollector({
hasUrl,
saveUrl,
savePreload,
saveFontData,
}: Omit<CreateUrlProxyParams, 'local'>): DataCollector {
return {
collect({ originalUrl, hash, preload, data }) {
if (!hasUrl(hash)) {
saveUrl(hash, originalUrl);
if (preload) {
savePreload(preload);
}
}
saveFontData({
hash,
url: originalUrl,
data,
});
},
};
}
34 changes: 34 additions & 0 deletions packages/astro/src/assets/fonts/implementations/error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js';

function getProps(input: ErrorHandlerInput): ConstructorParameters<typeof AstroError>[0] {
if (input.type === 'cannot-load-font-provider') {
return {
...AstroErrorData.CannotLoadFontProvider,
message: AstroErrorData.CannotLoadFontProvider.message(input.data.entrypoint),
};
} else if (input.type === 'unknown-fs-error') {
return AstroErrorData.UnknownFilesystemError;
} else if (input.type === 'cannot-fetch-font-file') {
return {
...AstroErrorData.CannotFetchFontFile,
message: AstroErrorData.CannotFetchFontFile.message(input.data.url),
};
} else if (input.type === 'cannot-extract-font-type') {
return {
...AstroErrorData.CannotExtractFontType,
message: AstroErrorData.CannotExtractFontType.message(input.data.url),
};
}
input satisfies never;
// Should never happen but TS isn't happy
return AstroErrorData.UnknownError;
}

export function createAstroErrorHandler(): ErrorHandler {
return {
handle(input) {
return new AstroError(getProps(input), { cause: input.cause });
},
};
}
41 changes: 41 additions & 0 deletions packages/astro/src/assets/fonts/implementations/font-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Storage } from 'unstorage';
import type { ErrorHandler, FontFetcher } from '../definitions.js';
import { cache } from '../utils.js';
import { isAbsolute } from 'node:path';

export function createCachedFontFetcher({
storage,
errorHandler,
fetch,
readFile,
}: {
storage: Storage;
errorHandler: ErrorHandler;
fetch: (url: string) => Promise<Response>;
readFile: (url: string) => Promise<Buffer>;
}): FontFetcher {
return {
async fetch(hash, url) {
return await cache(storage, hash, async () => {
try {
if (isAbsolute(url)) {
return await readFile(url);
}
// TODO: find a way to pass headers
// https://github.com/unjs/unifont/issues/143
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response was not successful, received status code ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (cause) {
throw errorHandler.handle({
type: 'cannot-fetch-font-file',
data: { url },
cause,
});
}
});
},
};
}
Loading
Loading