Skip to content

feat(fonts): experimental release #12775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 68 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
6391469
feat: setup branch
florian-lefebvre Dec 18, 2024
fdb3141
Merge branch 'main' into feat/fonts
florian-lefebvre Jan 17, 2025
21b6f35
Merge branch 'main' into feat/fonts
florian-lefebvre Jan 21, 2025
95673fc
feat(fonts): config (#12777)
florian-lefebvre Jan 21, 2025
c6cd69c
Merge branch 'main' into feat/fonts
florian-lefebvre Jan 30, 2025
3c18360
feat(fonts): vite plugin (#13093)
florian-lefebvre Feb 7, 2025
338757d
Merge branch 'main' into feat/fonts
florian-lefebvre Feb 7, 2025
4899359
Merge branch 'main' into feat/fonts
florian-lefebvre Feb 10, 2025
c6bc00f
feat(fonts): rename component (#13207)
florian-lefebvre Feb 10, 2025
363faf9
feat(fonts): add more providers (#13208)
florian-lefebvre Feb 11, 2025
193a9c0
feat(fonts): local provider (#13209)
florian-lefebvre Feb 17, 2025
e97e42a
Merge branch 'main' into feat/fonts
florian-lefebvre Feb 17, 2025
e8aead8
feat(fonts): improve providers (#13263)
florian-lefebvre Feb 18, 2025
918ee30
feat(fonts): improve type handling (#13270)
florian-lefebvre Feb 20, 2025
8bc05a0
feat(fonts): improve cache (#13273)
florian-lefebvre Feb 20, 2025
287b789
refactor(fonts): url proxy (#13274)
florian-lefebvre Feb 20, 2025
868e2ab
feat(fonts): dev headers (#13275)
florian-lefebvre Feb 20, 2025
8e89e80
feat(fonts): better local provider (#13276)
florian-lefebvre Feb 21, 2025
c515886
Merge branch 'main' into feat/fonts
florian-lefebvre Feb 27, 2025
08eff8c
Merge branch 'main' into feat/fonts
florian-lefebvre Feb 27, 2025
4eef143
feat(fonts): fallbacks (#13331)
florian-lefebvre Mar 3, 2025
a84a8b5
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 3, 2025
d8e0743
feat(fonts): refactor loading logic (#13353)
florian-lefebvre Mar 4, 2025
f62a7b7
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 5, 2025
75b1f5b
feat(fonts): css vars (#13362)
florian-lefebvre Mar 5, 2025
0f3419c
feat(fonts): as prop (#13366)
florian-lefebvre Mar 10, 2025
4a784be
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 10, 2025
1f3fc18
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 12, 2025
7ce73a1
feat(fonts): resolve config todos (#13401)
florian-lefebvre Mar 12, 2025
784976d
feat(fonts): use capsize instead of fontaine (#13411)
florian-lefebvre Mar 13, 2025
f01739d
feat(fonts): family name validation (#13417)
florian-lefebvre Mar 13, 2025
22ed30c
fix: zod error path
florian-lefebvre Mar 14, 2025
ef6f9ce
fix(fonts): vite plugin cleanup (#13424)
florian-lefebvre Mar 14, 2025
b70d8c4
feat(fonts): allow disabling automatic fallback generation (#13442)
florian-lefebvre Mar 18, 2025
c3845c8
feat(fonts): add errors (#13425)
florian-lefebvre Mar 19, 2025
e42de26
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 20, 2025
93ffd30
feat(fonts): typegen for <Font /> family prop (#13465)
florian-lefebvre Mar 21, 2025
b6af656
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 21, 2025
2665ebc
Update packages/astro/src/core/errors/errors-data.ts
florian-lefebvre Mar 25, 2025
dc24bdd
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 25, 2025
b470183
Apply suggestions from code review
florian-lefebvre Mar 26, 2025
3fcdba0
fix(fonts): config family types for inferred provider (#13469)
florian-lefebvre Mar 26, 2025
100cd48
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 26, 2025
fedde31
Merge branch 'main' into feat/fonts
florian-lefebvre Mar 30, 2025
af0e0e1
fix(fonts): throw error on unsuccessful responses (#13517)
florian-lefebvre Mar 31, 2025
4250554
fix(fonts): read local fonts files to extract metrics (#13518)
florian-lefebvre Mar 31, 2025
d893932
feat(fonts): set default fallbacks (#13516)
florian-lefebvre Mar 31, 2025
6cd4c14
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 1, 2025
d7b2b51
feat(fonts): update config shape (#13515)
florian-lefebvre Apr 2, 2025
0dbe842
feat(fonts): rename provider type helper (#13533)
florian-lefebvre Apr 2, 2025
78c88b2
feat(fonts): simpler logging (#13535)
florian-lefebvre Apr 2, 2025
0730611
fix(fonts): do not add quotes to family names (#13534)
florian-lefebvre Apr 2, 2025
95b9fef
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 2, 2025
c22561c
feat(fonts): new cssVariable property (#13544)
florian-lefebvre Apr 3, 2025
b350235
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 3, 2025
118a707
feat(fonts): update local family shape (#13553)
florian-lefebvre Apr 4, 2025
7d50c96
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 4, 2025
0362529
feat(fonts): improve local font files deletion (#13559)
florian-lefebvre Apr 4, 2025
8ae9be0
feat(fonts): update local to support entrypoints src and techs (#13556)
florian-lefebvre Apr 4, 2025
5e47be6
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 7, 2025
8454fc9
fix(fonts): patch unifont (#13574)
florian-lefebvre Apr 7, 2025
854eca9
feat(fonts): remove default provider (#13572)
florian-lefebvre Apr 7, 2025
c694f23
Merge branch 'main' into feat/fonts
florian-lefebvre Apr 10, 2025
d44e4f1
feat(fonts): T&D feedback (#13597)
florian-lefebvre Apr 10, 2025
be114f2
feat(fonts): jsdocs (#13600)
florian-lefebvre Apr 11, 2025
413af84
Update .changeset/happy-spies-punch.md
florian-lefebvre Apr 13, 2025
0705f78
Merge remote-tracking branch 'origin/main' into feat/fonts
ematipico Apr 14, 2025
4ee7308
rebase
ematipico Apr 14, 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
41 changes: 41 additions & 0 deletions .changeset/happy-spies-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'astro': minor
---

Adds a new, experimental Fonts API to provide first-party support for fonts in Astro.

This experimental feature allows you to use fonts from both your file system and several built-in supported providers (e.g. Google, Fontsource, Bunny) through a unified API. Keep your site performant thanks to sensible defaults and automatic optimizations including fallback font generation.

To enable this feature, configure an `experimental.fonts` object with one or more fonts:

```js title="astro.config.mjs"
import { defineConfig, fontProviders } from "astro/config"

export default defineConfig({
experimental: {
fonts: [{
provider: fontProviders.google(),
` name: "Roboto",
cssVariable: "--font-roboto",
}]
}
})
```

Then, add a `<Font />` component and site-wide styling in your `<head>`:

```astro title="src/components/Head.astro"
---
import { Font } from 'astro:assets'
---
<Font cssVariable='--font-roboto' preload />
<style>
body {
font-family: var(--font-roboto);
}
</style>
```

Visit [the experimental Fonts documentation](https://docs.astro.build/en/reference/experimental-flags/fonts/) for the full API, how to get started, and even how to build your own custom `AstroFontProvider` if we don't yet support your preferred font service.

For a complete overview, and to give feedback on this experimental API, see the [Fonts RFC](https://github.com/withastro/roadmap/pull/1039) and help shape its future.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"workerd",
"@biomejs/biome",
"sharp"
]
],
"patchedDependencies": {
"[email protected]": "patches/[email protected]"
}
}
}
3 changes: 3 additions & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference path="./types/content.d.ts" />
/// <reference path="./types/actions.d.ts" />
/// <reference path="./types/env.d.ts" />
/// <reference path="./types/fonts.d.ts" />

interface ImportMetaEnv {
/**
Expand Down Expand Up @@ -54,6 +55,7 @@ declare module 'astro:assets' {
inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize;
Image: typeof import('./components/Image.astro').default;
Picture: typeof import('./components/Picture.astro').default;
Font: typeof import('./components/Font.astro').default;
};

type ImgAttributes = import('./dist/type-utils.js').WithRequired<
Expand All @@ -73,6 +75,7 @@ declare module 'astro:assets' {
imageConfig,
Image,
Picture,
Font,
inferRemoteSize,
}: AstroAssets;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/astro/components/Font.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';

// TODO: remove dynamic import when fonts are stabilized
const { fontsData } = await import('virtual:astro:assets/fonts/internal').catch(() => {
throw new AstroError(AstroErrorData.ExperimentalFontsNotEnabled);
});

interface Props {
/** The `cssVariable` registered in your Astro configuration. */
cssVariable: import('astro:assets').FontFamily;
/** Whether it should output [preload links](https://web.dev/learn/performance/optimize-web-fonts#preload) or not. */
preload?: boolean;
}

const { cssVariable, preload = false } = Astro.props;
const data = fontsData.get(cssVariable);
if (!data) {
throw new AstroError({
...AstroErrorData.FontFamilyNotFound,
message: AstroErrorData.FontFamilyNotFound.message(cssVariable),
});
}
---

<style set:html={data.css}></style>
{
preload &&
data.preloadData.map(({ url, type }) => (
<link rel="preload" href={url} as="font" type={`font/${type}`} crossorigin />
))
}
6 changes: 5 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
"./assets/services/sharp": "./dist/assets/services/sharp.js",
"./assets/services/noop": "./dist/assets/services/noop.js",
"./assets/fonts/providers/*": "./dist/assets/fonts/providers/entrypoints/*.js",
"./loaders": "./dist/content/loaders/index.js",
"./content/runtime": "./dist/content/runtime.js",
"./content/runtime-assets": "./dist/content/runtime-assets.js",
Expand Down Expand Up @@ -113,7 +114,7 @@
"test:e2e:match": "playwright test -g",
"test:e2e:chrome": "playwright test",
"test:e2e:firefox": "playwright test --config playwright.firefox.config.js",
"test:types": "tsc --project tsconfig.tests.json",
"test:types": "tsc --project test/types/tsconfig.json",
"test:unit": "astro-scripts test \"test/units/**/*.test.js\" --teardown ./test/units/teardown.js",
"test:integration": "astro-scripts test \"test/*.test.js\""
},
Expand All @@ -122,6 +123,8 @@
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@astrojs/telemetry": "workspace:*",
"@capsizecss/metrics": "^3.5.0",
"@capsizecss/unpack": "^2.4.0",
"@oslojs/encoding": "^1.1.0",
"@rollup/pluginutils": "^5.1.4",
"acorn": "^8.14.1",
Expand Down Expand Up @@ -164,6 +167,7 @@
"tinyglobby": "^0.2.12",
"tsconfck": "^3.1.5",
"ultrahtml": "^1.6.0",
"unifont": "^0.1.7",
"unist-util-visit": "^5.0.0",
"unstorage": "^1.15.0",
"vfile": "^6.0.3",
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/actions/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { addRollupInput } from '../core/build/add-rollup-input.js';
import type { BuildInternals } from '../core/build/internal.js';
import type { StaticBuildOptions } from '../core/build/types.js';
import { shouldAppendForwardSlash } from '../core/build/util.js';
import { getOutputDirectory } from '../prerender/utils.js';
import { getServerOutputDirectory } from '../prerender/utils.js';
import type { AstroSettings } from '../types/astro.js';
import {
ASTRO_ACTIONS_INTERNAL_MODULE_ID,
Expand Down Expand Up @@ -73,7 +73,7 @@ export function vitePluginActionsBuild(
chunk.type !== 'asset' &&
chunk.facadeModuleId === RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID
) {
const outputDirectory = getOutputDirectory(opts.settings);
const outputDirectory = getServerOutputDirectory(opts.settings);
internals.astroActionsEntryPoint = new URL(chunkName, outputDirectory);
}
}
Expand Down
11 changes: 11 additions & 0 deletions packages/astro/src/assets/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# fonts

The vite plugin orchestrates the fonts logic:

- Retrieves data from the config
- Initializes font providers
- Fetches fonts data
- In dev, serves a middleware that dynamically loads and caches fonts data
- In build, download fonts data (from cache if possible)

The `<Font />` component is the only aspect not managed in the vite plugin, since it's exported from `astro:assets`.
174 changes: 174 additions & 0 deletions packages/astro/src/assets/fonts/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { LOCAL_PROVIDER_NAME } from './constants.js';

const weightSchema = z.union([z.string(), z.number()]);
const styleSchema = z.enum(['normal', 'italic', 'oblique']);

const familyPropertiesSchema = z.object({
/**
* A [font weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights:
*
* ```js
* weight: "100 900"
* ```
*/
weight: weightSchema,
/**
* A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style).
*/
style: styleSchema,
/**
* @default `"swap"`
*
* A [font display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display).
*/
display: z.enum(['auto', 'block', 'swap', 'fallback', 'optional']).optional(),
/**
* A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
*/
unicodeRange: z.array(z.string()).nonempty().optional(),
/**
* A [font stretch](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch).
*/
stretch: z.string().optional(),
/**
* Font [feature settings](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-feature-settings).
*/
featureSettings: z.string().optional(),
/**
* Font [variation settings](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-variation-settings).
*/
variationSettings: z.string().optional(),
});

const fallbacksSchema = z.object({
/**
* @default `["sans-serif"]`
*
* An array of fonts to use when your chosen font is unavailable, or loading. Fallback fonts will be chosen in the order listed. The first available font will be used:
*
* ```js
* fallbacks: ["CustomFont", "serif"]
* ```
*
* To disable fallback fonts completely, configure an empty array:
*
* ```js
* fallbacks: []
* ```
*

* 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.
*/
fallbacks: z.array(z.string()).nonempty().optional(),
/**
* @default `true`
*
* Whether or not to enable optimized fallback generation. You may disable this default optimization to have full control over `fallbacks`.
*/
optimizedFallbacks: z.boolean().optional(),
});

export const requiredFamilyAttributesSchema = z.object({
/**
* The font family name, as identified by your font provider.
*/
name: z.string(),
/**
* A valid [ident](https://developer.mozilla.org/en-US/docs/Web/CSS/ident) in the form of a CSS variable (i.e. starting with `--`).
*/
cssVariable: z.string(),
});

const entrypointSchema = z.union([z.string(), z.instanceof(URL)]);

export const fontProviderSchema = z
.object({
/**
* URL, path relative to the root or package import.
*/
entrypoint: entrypointSchema,
/**
* Optional serializable object passed to the unifont provider.
*/
config: z.record(z.string(), z.any()).optional(),
})
.strict();

export const localFontFamilySchema = requiredFamilyAttributesSchema
.merge(fallbacksSchema)
.merge(
z.object({
/**
* The source of your font files. Set to `"local"` to use local font files.
*/
provider: z.literal(LOCAL_PROVIDER_NAME),
/**
* Each variant represents a [`@font-face` declaration](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/).
*/
variants: z
.array(
familyPropertiesSchema.merge(
z
.object({
/**
* Font [sources](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src). It can be a path relative to the root, a package import or a URL. URLs are particularly useful if you inject local fonts through an integration.
*/
src: z
.array(
z.union([
entrypointSchema,
z.object({ url: entrypointSchema, tech: z.string().optional() }).strict(),
]),
)
.nonempty(),
// TODO: find a way to support subsets (through fontkit?)
})
.strict(),
),
)
.nonempty(),
}),
)
.strict();

export const remoteFontFamilySchema = requiredFamilyAttributesSchema
.merge(
familyPropertiesSchema.omit({
weight: true,
style: true,
}),
)
.merge(fallbacksSchema)
.merge(
z.object({
/**
* The source of your font files. You can use a built-in provider or write your own custom provider.
*/
provider: fontProviderSchema,
/**
* @default `[400]`
*
* An array of [font weights](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight). If the associated font is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_fonts/Variable_fonts_guide), you can specify a range of weights:
*
* ```js
* weight: "100 900"
* ```
*/
weights: z.array(weightSchema).nonempty().optional(),
/**
* @default `["normal", "italic"]`
*
* 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):
*/
subsets: z.array(z.string()).nonempty().optional(),
}),
)
.strict();
40 changes: 40 additions & 0 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ResolvedRemoteFontFamily } from './types.js';

export const LOCAL_PROVIDER_NAME = 'local';

export const 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;

// Requires a trailing slash
export const URL_PREFIX = '/_astro/fonts/';
export const CACHE_DIR = './fonts/';

export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'] as const;

// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75
export const DEFAULT_FALLBACKS: Record<string, Array<string>> = {
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: [],
};

export const FONTS_TYPES_FILE = 'fonts.d.ts';
Loading
Loading