From 6391469cda6249455cb194a3e282a2256d07f4b1 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 18 Dec 2024 15:43:52 +0100 Subject: [PATCH 01/40] feat: setup branch --- .changeset/happy-spies-punch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/happy-spies-punch.md diff --git a/.changeset/happy-spies-punch.md b/.changeset/happy-spies-punch.md new file mode 100644 index 000000000000..870826b3eed3 --- /dev/null +++ b/.changeset/happy-spies-punch.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Adds first-party support for fonts From 95673fcd21f1d346695c0032b0fb8a2b05b786f8 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 21 Jan 2025 16:09:48 +0100 Subject: [PATCH 02/40] feat(fonts): config (#12777) --- packages/astro/package.json | 2 +- packages/astro/src/assets/fonts/constants.ts | 4 + packages/astro/src/assets/fonts/helpers.ts | 5 + packages/astro/src/assets/fonts/providers.ts | 6 + .../astro/src/assets/fonts/providers/adobe.ts | 9 + .../src/assets/fonts/providers/google.ts | 10 + .../astro/src/assets/fonts/providers/local.ts | 10 + packages/astro/src/assets/fonts/types.ts | 27 ++ packages/astro/src/config/entrypoint.ts | 1 + packages/astro/src/config/index.ts | 7 +- packages/astro/src/core/config/schema.ts | 48 ++++ packages/astro/src/types/public/config.ts | 46 ++++ packages/astro/test/types/define-config.ts | 246 ++++++++++++++++-- .../types/tsconfig.json} | 3 +- .../test/units/config/config-validate.test.js | 73 +++++- 15 files changed, 465 insertions(+), 32 deletions(-) create mode 100644 packages/astro/src/assets/fonts/constants.ts create mode 100644 packages/astro/src/assets/fonts/helpers.ts create mode 100644 packages/astro/src/assets/fonts/providers.ts create mode 100644 packages/astro/src/assets/fonts/providers/adobe.ts create mode 100644 packages/astro/src/assets/fonts/providers/google.ts create mode 100644 packages/astro/src/assets/fonts/providers/local.ts create mode 100644 packages/astro/src/assets/fonts/types.ts rename packages/astro/{tsconfig.tests.json => test/types/tsconfig.json} (60%) diff --git a/packages/astro/package.json b/packages/astro/package.json index 2af7c5fc788a..71de5b5a9b64 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -113,7 +113,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\"" }, diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts new file mode 100644 index 000000000000..8a1868567134 --- /dev/null +++ b/packages/astro/src/assets/fonts/constants.ts @@ -0,0 +1,4 @@ +import { GOOGLE_PROVIDER_NAME } from "./providers/google.js"; +import { LOCAL_PROVIDER_NAME } from "./providers/local.js"; + +export const BUILTIN_PROVIDERS = [GOOGLE_PROVIDER_NAME, LOCAL_PROVIDER_NAME] as const; diff --git a/packages/astro/src/assets/fonts/helpers.ts b/packages/astro/src/assets/fonts/helpers.ts new file mode 100644 index 000000000000..e96a3b1a849b --- /dev/null +++ b/packages/astro/src/assets/fonts/helpers.ts @@ -0,0 +1,5 @@ +import type { FontProvider } from './types.js'; + +export function defineFontProvider(provider: FontProvider) { + return provider; +} diff --git a/packages/astro/src/assets/fonts/providers.ts b/packages/astro/src/assets/fonts/providers.ts new file mode 100644 index 000000000000..4ae9fda7cbfe --- /dev/null +++ b/packages/astro/src/assets/fonts/providers.ts @@ -0,0 +1,6 @@ +import { adobe } from './providers/adobe.js'; + +/** TODO: */ +export const fontProviders = { + adobe, +}; diff --git a/packages/astro/src/assets/fonts/providers/adobe.ts b/packages/astro/src/assets/fonts/providers/adobe.ts new file mode 100644 index 000000000000..a3a62e1ac578 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/adobe.ts @@ -0,0 +1,9 @@ +import { defineFontProvider } from '../helpers.js'; + +export function adobe(config: { apiKey: string }) { + return defineFontProvider({ + name: 'adobe', + entrypoint: 'astro/assets/fonts/providers/adobe', + config, + }); +} diff --git a/packages/astro/src/assets/fonts/providers/google.ts b/packages/astro/src/assets/fonts/providers/google.ts new file mode 100644 index 000000000000..d6b38f9b841a --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/google.ts @@ -0,0 +1,10 @@ +import { defineFontProvider } from '../helpers.js'; + +export const GOOGLE_PROVIDER_NAME = 'google'; + +export function google() { + return defineFontProvider({ + name: GOOGLE_PROVIDER_NAME, + entrypoint: 'astro/assets/fonts/providers/google', + }); +} diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts new file mode 100644 index 000000000000..6a3b8f9a14a4 --- /dev/null +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -0,0 +1,10 @@ +import { defineFontProvider } from '../helpers.js'; + +export const LOCAL_PROVIDER_NAME = 'local'; + +export function local() { + return defineFontProvider({ + name: LOCAL_PROVIDER_NAME, + entrypoint: 'astro/assets/fonts/providers/google', + }); +} diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts new file mode 100644 index 000000000000..c00c44825771 --- /dev/null +++ b/packages/astro/src/assets/fonts/types.ts @@ -0,0 +1,27 @@ +import type { BUILTIN_PROVIDERS } from './constants.js'; +import type { GOOGLE_PROVIDER_NAME } from './providers/google.js'; +import type { LOCAL_PROVIDER_NAME } from './providers/local.js'; + +export interface FontProvider { + name: TName; + entrypoint: string; + config?: Record; +} + +type LocalFontFamily = { + provider: LocalProviderName; + // TODO: refine type + src: string; +}; + +type StandardFontFamily = { + provider: TProvider; +}; + +export type FontFamily = TProvider extends LocalProviderName + ? LocalFontFamily + : StandardFontFamily; + +export type LocalProviderName = typeof LOCAL_PROVIDER_NAME; +export type GoogleProviderName = typeof GOOGLE_PROVIDER_NAME; +export type BuiltInProvider = (typeof BUILTIN_PROVIDERS)[number]; diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 4951792d63ae..614728b79c57 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -5,6 +5,7 @@ import type { ImageServiceConfig } from '../types/public/index.js'; export { defineConfig, getViteConfig } from './index.js'; export { envField } from '../env/config.js'; +export { fontProviders } from '../assets/fonts/providers.js'; /** * Return the configuration needed to use the Sharp-based image service diff --git a/packages/astro/src/config/index.ts b/packages/astro/src/config/index.ts index 7e7b548f1fcf..75f44fdc3f2b 100644 --- a/packages/astro/src/config/index.ts +++ b/packages/astro/src/config/index.ts @@ -7,6 +7,7 @@ import type { SessionDriverName, } from '../types/public/config.js'; import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js'; +import type { BuiltInProvider, FontFamily, FontProvider } from '../assets/fonts/types.js'; /** * See the full Astro Configuration API Documentation @@ -15,7 +16,11 @@ import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js export function defineConfig< const TLocales extends Locales = never, const TDriver extends SessionDriverName = never, ->(config: AstroUserConfig) { + const TFontProviders extends FontProvider[] = never, + const TFontFamilies extends FontFamily< + (TFontProviders extends never ? [] : TFontProviders)[number]['name'] | BuiltInProvider + >[] = never, +>(config: AstroUserConfig) { return config; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 8e9f510f8923..2efd6411c879 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -14,6 +14,7 @@ import type { SvgRenderMode } from '../../assets/utils/svg.js'; import { EnvSchema } from '../../env/schema.js'; import type { AstroUserConfig, ViteUserConfig } from '../../types/public/config.js'; import { appendForwardSlash, prependForwardSlash, removeTrailingForwardSlash } from '../path.js'; +import { BUILTIN_PROVIDERS } from '../../assets/fonts/constants.js'; // The below types are required boilerplate to workaround a Zod issue since v3.21.2. Since that version, // Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references @@ -589,6 +590,53 @@ export const AstroConfigSchema = z.object({ } return svgConfig; }), + fonts: z + .object({ + providers: z + .array( + z + .object({ + name: z.string().superRefine((name, ctx) => { + if (BUILTIN_PROVIDERS.includes(name as any)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${name}" is a reserved provider name`, + }); + } + }), + entrypoint: z.string(), + config: z.record(z.string(), z.any()).optional(), + }) + .strict(), + ) + .optional(), + families: z.array( + z + .object({ + provider: z.string(), + }) + .strict(), + ), + }) + .strict() + .optional() + .superRefine((fonts, ctx) => { + if (!fonts) { + return; + } + const providersNames = [ + ...BUILTIN_PROVIDERS, + ...(fonts.providers ?? []).map((provider) => provider.name), + ]; + for (const family of fonts.families) { + if (!providersNames.includes(family.provider)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid provider "${family.provider}". Please use of the following: ${providersNames.map((name) => `"${name}"`).join(', ')}`, + }); + } + } + }), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 635e57798c66..690ccc64cf81 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -17,8 +17,11 @@ import type { AstroCookieSetOptions } from '../../core/cookies/cookies.js'; import type { Logger, LoggerLevel } from '../../core/logger/core.js'; import type { EnvSchema } from '../../env/schema.js'; import type { AstroIntegration } from './integrations.js'; +import type { BuiltInProvider, FontFamily, FontProvider } from '../../assets/fonts/types.js'; export type Locales = (string | { codes: string[]; path: string })[]; +export type { FontProvider }; + type NormalizeLocales = { [K in keyof T]: T[K] extends string ? T[K] @@ -164,6 +167,10 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ export interface AstroUserConfig< TLocales extends Locales = never, TSession extends SessionDriverName = never, + TFontProviders extends FontProvider[] = never, + TFontFamilies extends FontFamily< + (TFontProviders extends never ? [] : TFontProviders)[number]['name'] | BuiltInProvider + >[] = never, > { /** * @docs @@ -2059,6 +2066,45 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ mode: SvgRenderMode; }; + + /** + * + * @name experimental.fonts + * @type {object} + * @default `undefined` + * @version 5.x + * @description + * + * TODO: + */ + fonts?: { + /** + * + * @name experimental.fonts.providers + * @type {FontProvider[]} + * @version 5.x + * @description + * + * TODO: + */ + providers?: [TFontProviders] extends [never] ? FontProvider[] : TFontProviders; + + /** + * + * @name experimental.fonts.families + * @type {FontFamily[]} + * @version 5.x + * @description + * + * TODO: + */ + families: [TFontFamilies] extends [never] + ? FontFamily< + | ([TFontProviders] extends [never] ? [] : TFontProviders)[number]['name'] + | BuiltInProvider + >[] + : TFontFamilies; + }; }; } diff --git a/packages/astro/test/types/define-config.ts b/packages/astro/test/types/define-config.ts index 7d68ae035344..8269d32aaf25 100644 --- a/packages/astro/test/types/define-config.ts +++ b/packages/astro/test/types/define-config.ts @@ -2,40 +2,234 @@ import { describe, it } from 'node:test'; import { expectTypeOf } from 'expect-type'; import { defineConfig } from '../../dist/config/index.js'; import type { AstroUserConfig } from '../../dist/types/public/index.js'; +import type { FontFamily, FontProvider } from '../../dist/assets/fonts/types.js'; + +function assertType(data: T, cb: (data: NoInfer) => void) { + cb(data); +} describe('defineConfig()', () => { - it('Infers generics correctly', () => { - const config_0 = defineConfig({}); - expectTypeOf(config_0).toEqualTypeOf>(); - expectTypeOf(config_0.i18n!.defaultLocale).toEqualTypeOf(); + it('Infers i18n generics correctly', () => { + assertType(defineConfig({}), (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf(); + }); - const config_1 = defineConfig({ - i18n: { - locales: ['en'], - defaultLocale: 'en', + assertType( + defineConfig({ + i18n: { + locales: ['en'], + defaultLocale: 'en', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en'>(); }, - }); - expectTypeOf(config_1).toEqualTypeOf>(); - expectTypeOf(config_1.i18n!.defaultLocale).toEqualTypeOf<'en'>(); + ); - const config_2 = defineConfig({ - i18n: { - locales: ['en', 'fr'], - defaultLocale: 'fr', + assertType( + defineConfig({ + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'fr', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr'>(); }, - }); - expectTypeOf(config_2).toEqualTypeOf>(); - expectTypeOf(config_2.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr'>(); + ); - const config_3 = defineConfig({ - i18n: { - locales: ['en', { path: 'french', codes: ['fr', 'fr-FR'] }], - defaultLocale: 'en', + assertType( + defineConfig({ + i18n: { + locales: ['en', { path: 'french', codes: ['fr', 'fr-FR'] }], + defaultLocale: 'en', + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + ['en', { readonly path: 'french'; readonly codes: ['fr', 'fr-FR'] }], + never, + never, + never + > + >(); + expectTypeOf(config.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr' | 'fr-FR'>(); }, + ); + }); + + it('Infers fonts generics correctly', () => { + assertType(defineConfig({}), (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf[]>(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf< + FontFamily<'google' | 'local'>[] + >(); }); - expectTypeOf(config_3).toEqualTypeOf< - AstroUserConfig<['en', { readonly path: 'french'; readonly codes: ['fr', 'fr-FR'] }]> - >(); - expectTypeOf(config_3.i18n!.defaultLocale).toEqualTypeOf<'en' | 'fr' | 'fr-FR'>(); + + assertType( + defineConfig({ + experimental: { + fonts: { + families: [], + }, + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf< + FontProvider[] + >(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf<[]>(); + }, + ); + + assertType( + defineConfig({ + experimental: { + fonts: { + families: [{ provider: 'google' }, { provider: 'local', src: 'test' }], + }, + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + never, + never, + never, + [ + { readonly provider: 'google' }, + { + readonly provider: 'local'; + readonly src: 'test'; + }, + ] + > + >(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf< + FontProvider[] + >(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf< + [ + { readonly provider: 'google' }, + { + readonly provider: 'local'; + readonly src: 'test'; + }, + ] + >(); + }, + ); + + assertType( + defineConfig({ + experimental: { + fonts: { + providers: [], + families: [], + }, + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf>(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf<[]>(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf<[]>(); + }, + ); + + assertType( + defineConfig({ + experimental: { + fonts: { + providers: [{ name: 'adobe', entrypoint: '' }], + families: [], + }, + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + never, + never, + [ + { + readonly name: 'adobe'; + readonly entrypoint: ''; + }, + ], + [] + > + >(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf< + [ + { + readonly name: 'adobe'; + readonly entrypoint: ''; + }, + ] + >(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf<[]>(); + }, + ); + + assertType( + defineConfig({ + experimental: { + fonts: { + providers: [{ name: 'adobe', entrypoint: '' }], + families: [ + { provider: 'google' }, + { provider: 'local', src: 'test' }, + { provider: 'adobe' }, + ], + }, + }, + }), + (config) => { + expectTypeOf(config).toEqualTypeOf< + AstroUserConfig< + never, + never, + [ + { + readonly name: 'adobe'; + readonly entrypoint: ''; + }, + ], + [ + { readonly provider: 'google' }, + { + readonly provider: 'local'; + readonly src: 'test'; + }, + { readonly provider: 'adobe' }, + ] + > + >(); + expectTypeOf(config.experimental!.fonts!.providers!).toEqualTypeOf< + [ + { + readonly name: 'adobe'; + readonly entrypoint: ''; + }, + ] + >(); + expectTypeOf(config.experimental!.fonts!.families).toEqualTypeOf< + [ + { readonly provider: 'google' }, + { + readonly provider: 'local'; + readonly src: 'test'; + }, + { readonly provider: 'adobe' }, + ] + >(); + }, + ); }); }); diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/test/types/tsconfig.json similarity index 60% rename from packages/astro/tsconfig.tests.json rename to packages/astro/test/types/tsconfig.json index 1984bc4fe66a..cc320a61e407 100644 --- a/packages/astro/tsconfig.tests.json +++ b/packages/astro/test/types/tsconfig.json @@ -1,6 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "include": ["test/types"], + "extends": "../../../../tsconfig.base.json", "compilerOptions": { "allowJs": true, "emitDeclarationOnly": false, diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index cfc52d8a9de1..b02de7c02dd3 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -370,7 +370,7 @@ describe('Config Validation', () => { }, }, process.cwd(), - ).catch((err) => err), + ), ); }); @@ -385,7 +385,7 @@ describe('Config Validation', () => { }, }, process.cwd(), - ).catch((err) => err), + ), ); }); @@ -427,4 +427,73 @@ describe('Config Validation', () => { ); }); }); + + describe('fonts', () => { + it('Should allow empty providers and families', () => { + assert.doesNotThrow(() => + validateConfig( + { + experimental: { + fonts: { + providers: [], + families: [], + }, + }, + }, + process.cwd(), + ), + ); + }); + + it('Should not allow providers with reserved names', async () => { + let configError = await validateConfig( + { + experimental: { + fonts: { + providers: [{ name: 'google', entrypoint: '' }], + families: [], + }, + }, + }, + process.cwd(), + ).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes('"google" is a reserved provider name'), + true, + ); + + configError = await validateConfig( + { + experimental: { + fonts: { + providers: [{ name: 'local', entrypoint: '' }], + families: [], + }, + }, + }, + process.cwd(), + ).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message.includes('"local" is a reserved provider name'), + true, + ); + }); + + it('Should not allow using non registed providers', async () => { + const configError = await validateConfig( + { + experimental: { + fonts: { + families: [{ provider: 'custom' }], + }, + }, + }, + process.cwd(), + ).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal(configError.errors[0].message.includes('Invalid provider "custom"'), true); + }); + }); }); From 3c1836040f1e1600effa9927a43c7f90f4fba6b1 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Fri, 7 Feb 2025 14:16:35 +0100 Subject: [PATCH 03/40] feat(fonts): vite plugin (#13093) --- packages/astro/client.d.ts | 1 + packages/astro/components/Fonts.astro | 25 ++ packages/astro/package.json | 2 + packages/astro/src/assets/fonts/README.md | 11 + packages/astro/src/assets/fonts/cache.ts | 62 ++++ packages/astro/src/assets/fonts/constants.ts | 21 +- packages/astro/src/assets/fonts/providers.ts | 60 +++- .../astro/src/assets/fonts/providers/adobe.ts | 7 +- .../src/assets/fonts/providers/google.ts | 8 + .../astro/src/assets/fonts/providers/local.ts | 9 +- packages/astro/src/assets/fonts/types.ts | 29 +- packages/astro/src/assets/fonts/utils.ts | 39 +++ .../src/assets/fonts/vite-plugin-fonts.ts | 307 ++++++++++++++++++ .../astro/src/assets/vite-plugin-assets.ts | 12 +- packages/astro/src/core/build/generate.ts | 4 +- packages/astro/src/core/build/pipeline.ts | 4 +- packages/astro/src/core/build/static-build.ts | 4 +- packages/astro/src/core/build/util.ts | 1 + packages/astro/src/core/config/schema.ts | 5 + packages/astro/src/core/create-vite.ts | 2 +- .../astro/src/core/middleware/vite-plugin.ts | 4 +- packages/astro/src/integrations/hooks.ts | 4 +- packages/astro/src/prerender/utils.ts | 9 +- .../test/fixtures/fonts/astro.config.mjs | 4 + .../astro/test/fixtures/fonts/package.json | 8 + .../test/fixtures/fonts/src/pages/index.astro | 5 + .../fixtures/fonts/src/pages/preload.astro | 5 + packages/astro/test/fonts.test.js | 72 ++++ packages/astro/test/types/define-config.ts | 43 ++- .../test/units/config/config-validate.test.js | 2 +- pnpm-lock.yaml | 32 ++ 31 files changed, 762 insertions(+), 39 deletions(-) create mode 100644 packages/astro/components/Fonts.astro create mode 100644 packages/astro/src/assets/fonts/README.md create mode 100644 packages/astro/src/assets/fonts/cache.ts create mode 100644 packages/astro/src/assets/fonts/utils.ts create mode 100644 packages/astro/src/assets/fonts/vite-plugin-fonts.ts create mode 100644 packages/astro/test/fixtures/fonts/astro.config.mjs create mode 100644 packages/astro/test/fixtures/fonts/package.json create mode 100644 packages/astro/test/fixtures/fonts/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/fonts/src/pages/preload.astro create mode 100644 packages/astro/test/fonts.test.js diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 543e00d8eb67..613823129d62 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -54,6 +54,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; + Fonts: typeof import('./components/Fonts.astro').default; }; type ImgAttributes = import('./dist/type-utils.js').WithRequired< diff --git a/packages/astro/components/Fonts.astro b/packages/astro/components/Fonts.astro new file mode 100644 index 000000000000..4c15c9314ea7 --- /dev/null +++ b/packages/astro/components/Fonts.astro @@ -0,0 +1,25 @@ +--- +// TODO: remove dynamic import when fonts are stabilized +const { fontsData } = await import('virtual:astro:assets/fonts/internal').catch(() => { + throw new Error('experimental.fonts not enabled'); +}); + +interface Props { + family: string; + preload?: boolean; +} + +const { family, preload = false } = Astro.props; +const data = fontsData.get(family); +if (!data) { + throw new Error(`No data for ${family}`); +} +--- + + +{ + preload && + data.preloadData.map(({ url, type }) => ( + + )) +} diff --git a/packages/astro/package.json b/packages/astro/package.json index 7bbf459ea212..98eed3a5d50b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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/*.js", "./loaders": "./dist/content/loaders/index.js", "./content/runtime": "./dist/content/runtime.js", "./content/runtime-assets": "./dist/content/runtime-assets.js", @@ -165,6 +166,7 @@ "tinyexec": "^0.3.2", "tsconfck": "^3.1.4", "ultrahtml": "^1.5.3", + "unifont": "^0.1.7", "unist-util-visit": "^5.0.0", "unstorage": "^1.14.4", "vfile": "^6.0.3", diff --git a/packages/astro/src/assets/fonts/README.md b/packages/astro/src/assets/fonts/README.md new file mode 100644 index 000000000000..edf22efe7ca3 --- /dev/null +++ b/packages/astro/src/assets/fonts/README.md @@ -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 `` component is the only aspect not managed in the vite plugin, since it's exported from `astro:assets`. \ No newline at end of file diff --git a/packages/astro/src/assets/fonts/cache.ts b/packages/astro/src/assets/fonts/cache.ts new file mode 100644 index 000000000000..05926196edff --- /dev/null +++ b/packages/astro/src/assets/fonts/cache.ts @@ -0,0 +1,62 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type * as unifont from 'unifont'; +import { AstroError, AstroErrorData } from '../../core/errors/index.js'; + +type Storage = Required['storage']; + +export const createStorage = ({ + base, +}: { + base: URL; +}): Storage => { + return { + getItem: async (key) => { + const dest = new URL('./' + key, base); + try { + if (!existsSync(dest)) { + return; + } + const content = await readFile(dest, 'utf-8'); + try { + return JSON.parse(content); + } catch { + // If we can't parse the content, we assume the entry does not exist + return; + } + } catch (e) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e }); + } + }, + setItem: async (key, value) => { + const dest = new URL('./' + key, base); + try { + await mkdir(dirname(fileURLToPath(dest)), { recursive: true }); + return await writeFile(dest, JSON.stringify(value), 'utf-8'); + } catch (e) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e }); + } + }, + }; +}; + +export const createCache = ({ storage }: { storage: Storage }) => { + return { + cache: async ( + key: string, + cb: () => Promise, + ): Promise<{ cached: boolean; data: string }> => { + const existing = await storage.getItem(key); + if (existing) { + return { cached: true, data: existing }; + } + const data = await cb(); + await storage.setItem(key, data); + return { cached: false, data }; + }, + }; +}; + +export type Cache = ReturnType; diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 8a1868567134..4d5bf556ecff 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,4 +1,21 @@ -import { GOOGLE_PROVIDER_NAME } from "./providers/google.js"; -import { LOCAL_PROVIDER_NAME } from "./providers/local.js"; +import { GOOGLE_PROVIDER_NAME } from './providers/google.js'; +import { LOCAL_PROVIDER_NAME } from './providers/local.js'; +import type * as unifont from 'unifont'; export const BUILTIN_PROVIDERS = [GOOGLE_PROVIDER_NAME, LOCAL_PROVIDER_NAME] as const; + +export const DEFAULTS: unifont.ResolveFontOptions = { + weights: ['400'], + styles: ['normal', 'italic'], + subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'], + fallbacks: undefined, +}; + +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']; diff --git a/packages/astro/src/assets/fonts/providers.ts b/packages/astro/src/assets/fonts/providers.ts index 4ae9fda7cbfe..9f4b05ac3456 100644 --- a/packages/astro/src/assets/fonts/providers.ts +++ b/packages/astro/src/assets/fonts/providers.ts @@ -1,6 +1,64 @@ +import { createRequire } from 'node:module'; +import type { AstroSettings } from '../../types/astro.js'; import { adobe } from './providers/adobe.js'; +import { google } from './providers/google.js'; +import { local } from './providers/local.js'; +import type { FontProvider, ResolvedFontProvider } from './types.js'; +import { fileURLToPath } from 'node:url'; +import type { ModuleLoader } from '../../core/module-loader/loader.js'; -/** TODO: */ +/** TODO: jsdoc */ export const fontProviders = { adobe, + // TODO: reexport all unifont providers }; + +function resolveEntrypoint(settings: AstroSettings, entrypoint: string): string { + const require = createRequire(settings.config.root); + + try { + return require.resolve(entrypoint); + } catch { + return fileURLToPath(new URL(entrypoint, settings.config.root)); + } +} + +async function resolveMod( + id: string, + moduleLoader?: ModuleLoader, +): Promise> { + try { + const mod = await (moduleLoader ? moduleLoader.import(id) : import(id)); + if (!mod.provider && typeof mod.provider !== 'function') { + // TODO: improve + throw new Error('Not a function'); + } + return { + provider: mod.provider, + }; + } catch (e) { + // TODO: AstroError + throw e; + } +} + +export async function resolveProviders({ + settings, + providers: _providers, + moduleLoader, +}: { + settings: AstroSettings; + providers: Array>; + moduleLoader?: ModuleLoader; +}): Promise> { + const providers = [google(), local(), ..._providers]; + const resolvedProviders: Array = []; + + for (const { name, entrypoint, config } of providers) { + const id = resolveEntrypoint(settings, entrypoint.toString()); + const { provider } = await resolveMod(id, moduleLoader); + resolvedProviders.push({ name, config, provider }); + } + + return resolvedProviders; +} diff --git a/packages/astro/src/assets/fonts/providers/adobe.ts b/packages/astro/src/assets/fonts/providers/adobe.ts index a3a62e1ac578..5d7417492f1b 100644 --- a/packages/astro/src/assets/fonts/providers/adobe.ts +++ b/packages/astro/src/assets/fonts/providers/adobe.ts @@ -1,9 +1,14 @@ import { defineFontProvider } from '../helpers.js'; +import { providers } from 'unifont'; -export function adobe(config: { apiKey: string }) { +type Provider = typeof providers.adobe; + +export function adobe(config: Parameters[0]) { return defineFontProvider({ name: 'adobe', entrypoint: 'astro/assets/fonts/providers/adobe', config, }); } + +export const provider: Provider = providers.adobe; diff --git a/packages/astro/src/assets/fonts/providers/google.ts b/packages/astro/src/assets/fonts/providers/google.ts index d6b38f9b841a..99ad7d9e655d 100644 --- a/packages/astro/src/assets/fonts/providers/google.ts +++ b/packages/astro/src/assets/fonts/providers/google.ts @@ -1,10 +1,18 @@ import { defineFontProvider } from '../helpers.js'; +import { providers } from 'unifont'; + +type Provider = typeof providers.google; export const GOOGLE_PROVIDER_NAME = 'google'; +// TODO: https://github.com/unjs/unifont/issues/108 +// This provider downloads too many files when there's a variable font +// available. This is bad because it doesn't align with our default font settings export function google() { return defineFontProvider({ name: GOOGLE_PROVIDER_NAME, entrypoint: 'astro/assets/fonts/providers/google', }); } + +export const provider: Provider = providers.google; diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index 6a3b8f9a14a4..94613f9f34a8 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,3 +1,4 @@ +import { providers } from 'unifont'; import { defineFontProvider } from '../helpers.js'; export const LOCAL_PROVIDER_NAME = 'local'; @@ -5,6 +6,12 @@ export const LOCAL_PROVIDER_NAME = 'local'; export function local() { return defineFontProvider({ name: LOCAL_PROVIDER_NAME, - entrypoint: 'astro/assets/fonts/providers/google', + entrypoint: 'astro/assets/fonts/providers/local', }); } + +// TODO: implement +export const provider = () => + Object.assign(providers.google(), { + _name: 'local', + }); diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts index c00c44825771..0d7a95af564d 100644 --- a/packages/astro/src/assets/fonts/types.ts +++ b/packages/astro/src/assets/fonts/types.ts @@ -1,26 +1,43 @@ import type { BUILTIN_PROVIDERS } from './constants.js'; import type { GOOGLE_PROVIDER_NAME } from './providers/google.js'; import type { LOCAL_PROVIDER_NAME } from './providers/local.js'; +import type * as unifont from 'unifont'; export interface FontProvider { name: TName; - entrypoint: string; + entrypoint: string | URL; config?: Record; } -type LocalFontFamily = { +export interface ResolvedFontProvider { + name: string; + provider: (config?: Record) => UnifontProvider; + config?: Record; +} + +export type UnifontProvider = unifont.Provider; + +// TODO: support optional as prop +interface FontFamilyAttributes extends Partial { + name: string; + provider: string; +} + +// TODO: make provider optional and default to google +interface LocalFontFamily extends Omit { provider: LocalProviderName; // TODO: refine type src: string; -}; +} -type StandardFontFamily = { +interface CommonFontFamily + extends Omit { provider: TProvider; -}; +} export type FontFamily = TProvider extends LocalProviderName ? LocalFontFamily - : StandardFontFamily; + : CommonFontFamily; export type LocalProviderName = typeof LOCAL_PROVIDER_NAME; export type GoogleProviderName = typeof GOOGLE_PROVIDER_NAME; diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts new file mode 100644 index 000000000000..61c87a4cddcf --- /dev/null +++ b/packages/astro/src/assets/fonts/utils.ts @@ -0,0 +1,39 @@ +import type * as unifont from 'unifont'; + +// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L7-L21 +export function generateFontFace(family: string, font: unifont.FontFaceData) { + return [ + '@font-face {', + ` font-family: '${family}';`, + ` src: ${renderFontSrc(font.src)};`, + ` font-display: ${font.display || 'swap'};`, + font.unicodeRange && ` unicode-range: ${font.unicodeRange};`, + font.weight && + ` font-weight: ${Array.isArray(font.weight) ? font.weight.join(' ') : font.weight};`, + font.style && ` font-style: ${font.style};`, + font.stretch && ` font-stretch: ${font.stretch};`, + font.featureSettings && ` font-feature-settings: ${font.featureSettings};`, + font.variationSettings && ` font-variation-settings: ${font.variationSettings};`, + `}`, + ] + .filter(Boolean) + .join('\n'); +} + +// Source: https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81 +function renderFontSrc(sources: Exclude[]) { + return sources + .map((src) => { + if ('url' in src) { + let rendered = `url("${src.url}")`; + for (const key of ['format', 'tech'] as const) { + if (key in src) { + rendered += ` ${key}(${src[key]})`; + } + } + return rendered; + } + return `local("${src.name}")`; + }) + .join(', '); +} diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts new file mode 100644 index 000000000000..2750fea5d582 --- /dev/null +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -0,0 +1,307 @@ +import type { Plugin } from 'vite'; +import type { AstroSettings } from '../../types/astro.js'; +import { resolveProviders } from './providers.js'; +import * as unifont from 'unifont'; +import type { FontFamily, FontProvider } from './types.js'; +import xxhash from 'xxhash-wasm'; +import { extname } from 'node:path'; +import { getClientOutputDirectory } from '../../prerender/utils.js'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { generateFontFace } from './utils.js'; +import { + DEFAULTS, + VIRTUAL_MODULE_ID, + RESOLVED_VIRTUAL_MODULE_ID, + URL_PREFIX, + CACHE_DIR, + FONT_TYPES, +} from './constants.js'; +import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path'; +import type { Logger } from '../../core/logger/core.js'; +import { createCache, createStorage, type Cache } from './cache.js'; +import { AstroError, AstroErrorData } from '../../core/errors/index.js'; +import type { ModuleLoader } from '../../core/module-loader/loader.js'; +import { createViteLoader } from '../../core/module-loader/vite.js'; + +// TODO: maybe rename Fonts component to Font + +interface Options { + settings: AstroSettings; + sync: boolean; + logger: Logger; +} + +/** + * Preload data is used for links generation inside the component + */ +type PreloadData = Array<{ + /** + * Absolute link to a font file, eg. /_astro/fonts/abc.woff + */ + url: string; + /** + * A font type, eg. woff2, woff, ttf... + */ + type: string; +}>; + +/** + * We want to show logs related to font downloading (fresh or from cache) + * However if we just use the logger as is, there are too many logs, and not + * so useful. + * This log manager allows avoiding repetitive logs: + * - If there are many downloads started at once, only one log is shown for start and end + * - If a given file has already been logged, it won't show up anymore (useful in dev) + */ +const createLogManager = (logger: Logger) => { + const done = new Set(); + const items = new Set(); + let id: NodeJS.Timeout | null = null; + + return { + add: (value: string) => { + if (done.has(value)) { + return; + } + + if (items.size === 0 && id === null) { + logger.info('assets', 'Downloading fonts...'); + } + items.add(value); + if (id) { + clearTimeout(id); + id = null; + } + }, + remove: (value: string, cached: boolean) => { + if (done.has(value)) { + return; + } + + items.delete(value); + done.add(value); + if (id) { + clearTimeout(id); + id = null; + } + id = setTimeout(() => { + let msg = 'Done'; + if (cached) { + msg += ' (loaded from cache)'; + } + logger.info('assets', msg); + }, 50); + }, + }; +}; + +async function fetchFont(url: string): Promise { + try { + const r = await fetch(url); + const arr = await r.arrayBuffer(); + return Buffer.from(arr).toString(); + } catch (e) { + // TODO: AstroError + throw new Error('Error downloading font file', { cause: e }); + } +} + +export function fonts({ settings, sync, logger }: Options): Plugin { + if (!settings.config.experimental.fonts) { + // this is required because the virtual module does not exist + // when fonts are not enabled, and that prevents rollup from building + // TODO: remove once fonts are stabilized + return { + name: 'astro:fonts:fallback', + config() { + return { + build: { + rollupOptions: { + external: [VIRTUAL_MODULE_ID], + }, + }, + }; + }, + }; + } + + const providers: Array> = settings.config.experimental.fonts.providers ?? []; + const families: Array> = settings.config.experimental.fonts.families; + + // We don't need to take the trailing slash and build output configuration options + // into account because we only serve (dev) or write (build) static assets (equivalent + // to trailingSlash: never) + const baseUrl = removeTrailingForwardSlash(settings.config.base) + URL_PREFIX; + + let resolvedMap: Map | null = null; + // Key is `${hash}.${ext}`, value is a URL. + // When a font file is requested (eg. /_astro/fonts/abc.woff), we use the hash + // to download the original file, or retrieve it from cache + let hashToUrlMap: Map | null = null; + let isBuild: boolean; + let cache: Cache['cache'] | null = null; + + async function initialize(moduleLoader?: ModuleLoader) { + const { h64ToString } = await xxhash(); + + const resolved = await resolveProviders({ + settings, + providers, + moduleLoader, + }); + + const storage = createStorage({ + // In dev, we cache fonts data in .astro so it can be easily inspected and cleared + base: isBuild + ? new URL(CACHE_DIR, settings.config.cacheDir) + : new URL(CACHE_DIR, settings.dotAstroDir), + }); + cache = createCache({ storage }).cache; + + const { resolveFont } = await unifont.createUnifont( + resolved.map((e) => e.provider(e.config)), + { storage }, + ); + // We initialize shared variables here and reset them in buildEnd + // to avoid locking memory + resolvedMap = new Map(); + hashToUrlMap = new Map(); + for (const family of families) { + const resolvedOptions: unifont.ResolveFontOptions = { + weights: family.weights ?? DEFAULTS.weights, + styles: family.styles ?? DEFAULTS.styles, + subsets: family.subsets ?? DEFAULTS.subsets, + fallbacks: family.fallbacks ?? DEFAULTS.fallbacks, + }; + const { fonts: fontsData, fallbacks } = await resolveFont(family.name, resolvedOptions, [ + family.provider, + ]); + + // TODO: investigate using fontaine for fallbacks + const preloadData: PreloadData = []; + let css = ''; + for (const data of fontsData) { + for (const source of data.src as unknown as Array>) { + // Types are wonky but a source is + // 1. local and has a name + // 2. remote and has an url + // Once we have the key, it's safe to access the related source property + const key = 'name' in source ? 'name' : 'url'; + const value = source[key]; + const hash = h64ToString(value) + extname(value); + const url = baseUrl + hash; + if (!hashToUrlMap.has(hash)) { + hashToUrlMap.set(hash, value); + const segments = hash.split('.'); + // It's safe, there's at least 1 member in the array + const type = segments.at(-1)!; + if (segments.length === 1 || !FONT_TYPES.includes(type)) { + // TODO: AstroError + throw new Error("can't extract type from filename"); + } + // TODO: investigate if the extension matches the type, see https://github.com/unjs/unifont/blob/fd3828f6f809f54a188a9eb220e7eb99b3ec3960/src/css/parse.ts#L15-L22 + preloadData.push({ url, type }); + } + // Now that we collected the original url, we override it with our proxy + source[key] = url; + } + css += generateFontFace(family.name, data); + } + resolvedMap.set(family.name, { preloadData, css }); + } + logger.info('assets', 'Fonts initialized'); + } + + return { + name: 'astro:fonts', + config(_, { command }) { + isBuild = command === 'build'; + }, + async buildStart() { + if (isBuild) { + await initialize(); + } + }, + async configureServer(server) { + const moduleLoader = createViteLoader(server); + await initialize(moduleLoader); + + const logManager = createLogManager(logger); + // Base is taken into account by default. The prefix contains a traling slash, + // so it matches correctly any hash, eg. /_astro/fonts/abc.woff => abc.woff + server.middlewares.use(URL_PREFIX, async (req, res, next) => { + if (!req.url) { + return next(); + } + const hash = req.url.slice(1); + const url = hashToUrlMap?.get(hash); + if (!url) { + return next(); + } + logManager.add(hash); + // Cache should be defined at this point since initialize it called before registering + // the middleware. hashToUrlMap is defined at the same time so if it's not set by now, + // no url will be matched and this line will not be reached. + const { cached, data } = await cache!(hash, () => fetchFont(url)); + logManager.remove(hash, cached); + + // TODO: add cache control back + // TODO: set content type and cache control manually + // const keys = ['cache-control', 'content-type', 'content-length']; + // const keys = ['content-type', 'content-length']; + // for (const key of keys) { + // const value = response.headers.get(key); + // if (value) { + // res.setHeader(key, value); + // } + // } + res.end(data); + }); + }, + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + load(id, opts) { + if (id === RESOLVED_VIRTUAL_MODULE_ID && opts?.ssr) { + return ` + export const fontsData = new Map(${JSON.stringify(Array.from(resolvedMap?.entries() ?? []))}) + `; + } + }, + async buildEnd() { + resolvedMap = null; + + if (sync) { + hashToUrlMap = null; + cache = null; + return; + } + + const logManager = createLogManager(logger); + const dir = getClientOutputDirectory(settings); + const fontsDir = new URL('.' + baseUrl, dir); + try { + mkdirSync(fontsDir, { recursive: true }); + } catch (e) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e }); + } + await Promise.all( + Array.from(hashToUrlMap!.entries()).map(async ([hash, url]) => { + logManager.add(hash); + const { cached, data } = await cache!(hash, () => fetchFont(url)); + logManager.remove(hash, cached); + try { + writeFileSync(new URL(hash, fontsDir), data); + } catch (e) { + throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e }); + } + }), + ); + + hashToUrlMap = null; + cache = null; + }, + }; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index c3f37b8f9036..f0dfce05ebd6 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -19,6 +19,8 @@ import { emitESMImage } from './utils/node/emitAsset.js'; import { getProxyCode } from './utils/proxy.js'; import { makeSvgComponent } from './utils/svg.js'; import { hashTransform, propsToFilename } from './utils/transformToPath.js'; +import { fonts } from './fonts/vite-plugin-fonts.js'; +import type { Logger } from '../core/logger/core.js'; const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; @@ -90,7 +92,13 @@ const addStaticImageFactory = ( }; }; -export default function assets({ settings }: { settings: AstroSettings }): vite.Plugin[] { +interface Options { + settings: AstroSettings; + sync: boolean; + logger: Logger; +} + +export default function assets({ settings, sync, logger }: Options): vite.Plugin[] { let resolvedConfig: vite.ResolvedConfig; let shouldEmitFile = false; let isBuild = false; @@ -122,6 +130,7 @@ export default function assets({ settings }: { settings: AstroSettings }): vite. import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; + export { default as Fonts } from "astro/components/Fonts.astro"; export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js"; export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })}; @@ -243,5 +252,6 @@ export default function assets({ settings }: { settings: AstroSettings }): vite. } }, }, + fonts({ settings, sync, logger }), ]; } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 5a9f8a6aeb77..fefeeafe2055 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -17,7 +17,7 @@ import { } from '../../core/path.js'; import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js'; import { runHookBuildGenerated } from '../../integrations/hooks.js'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings, ComponentInstance } from '../../types/astro.js'; import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js'; import type { AstroConfig } from '../../types/public/config.js'; @@ -57,7 +57,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil if (ssr) { manifest = await BuildPipeline.retrieveManifest(options.settings, internals); } else { - const baseDirectory = getOutputDirectory(options.settings); + const baseDirectory = getServerOutputDirectory(options.settings); const renderersEntryUrl = new URL('renderers.mjs', baseDirectory); const renderers = await import(renderersEntryUrl.toString()); const middleware: MiddlewareHandler = internals.middlewareEntryPoint diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 70be64fdf171..996b6fc69e21 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -1,4 +1,4 @@ -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings, ComponentInstance } from '../../types/astro.js'; import type { RewritePayload } from '../../types/public/common.js'; import type { @@ -115,7 +115,7 @@ export class BuildPipeline extends Pipeline { settings: AstroSettings, internals: BuildInternals, ): Promise { - const baseDirectory = getOutputDirectory(settings); + const baseDirectory = getServerOutputDirectory(settings); const manifestEntryUrl = new URL( `${internals.manifestFileName}?time=${Date.now()}`, baseDirectory, diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 71046956c8ce..0353d03237be 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -9,7 +9,7 @@ import { type BuildInternals, createBuildInternals } from '../../core/build/inte import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { runHookBuildSetup } from '../../integrations/hooks.js'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { RouteData } from '../../types/public/internal.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { routeIsRedirect } from '../redirects/index.js'; @@ -142,7 +142,7 @@ async function ssrBuild( ) { const { allPages, settings, viteConfig } = opts; const ssr = settings.buildOutput === 'server'; - const out = getOutputDirectory(settings); + const out = getServerOutputDirectory(settings); const routes = Object.values(allPages).flatMap((pageData) => pageData.route); const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input); const viteBuildConfig: vite.InlineConfig = { diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index b6b313254379..76894fb812e0 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -67,3 +67,4 @@ export function viteBuildReturnToRollupOutputs( } return result; } + diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 2a4e4dd6e1ad..cf2436bc1908 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -611,10 +611,15 @@ export const AstroConfigSchema = z.object({ .strict(), ) .optional(), + // TODO: allow string and transform + // TODO: dedupe based on name and as families: z.array( z .object({ + name: z.string(), provider: z.string(), + // TODO: discriminated union + src: z.string().optional(), }) .strict(), ), diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 6e10ec38ce11..dc1eb177d5e5 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -164,7 +164,7 @@ export async function createVite( astroContentAssetPropagationPlugin({ settings }), vitePluginMiddleware({ settings }), vitePluginSSRManifest(), - astroAssetsPlugin({ settings }), + astroAssetsPlugin({ settings, sync, logger }), astroPrefetch({ settings }), astroTransitions({ settings }), astroDevToolbar({ settings, logger }), diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts index 2587c4565e90..e3e0aa823725 100644 --- a/packages/astro/src/core/middleware/vite-plugin.ts +++ b/packages/astro/src/core/middleware/vite-plugin.ts @@ -1,5 +1,5 @@ import type { Plugin as VitePlugin } from 'vite'; -import { getOutputDirectory } from '../../prerender/utils.js'; +import { getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroSettings } from '../../types/astro.js'; import { addRollupInput } from '../build/add-rollup-input.js'; import type { BuildInternals } from '../build/internal.js'; @@ -112,7 +112,7 @@ export function vitePluginMiddlewareBuild( writeBundle(_, bundle) { for (const [chunkName, chunk] of Object.entries(bundle)) { if (chunk.type !== 'asset' && chunk.facadeModuleId === MIDDLEWARE_MODULE_ID) { - const outputDirectory = getOutputDirectory(opts.settings); + const outputDirectory = getServerOutputDirectory(opts.settings); internals.middlewareEntryPoint = new URL(chunkName, outputDirectory); } } diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 5a4b723b2000..6c6720994f84 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -32,6 +32,7 @@ import type { } from '../types/public/integrations.js'; import type { RouteData } from '../types/public/internal.js'; import { validateSupportedFeatures } from './features-validation.js'; +import { getClientOutputDirectory } from '../prerender/utils.js'; async function withTakingALongTimeMsg({ name, @@ -586,8 +587,7 @@ type RunHookBuildDone = { }; export async function runHookBuildDone({ settings, pages, routes, logging }: RunHookBuildDone) { - const dir = - settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir; + const dir = getClientOutputDirectory(settings); await fsMod.promises.mkdir(dir, { recursive: true }); const integrationRoutes = routes.map(toIntegrationRouteData); for (const integration of settings.config.integrations) { diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index 06ddc09efbaa..91f9fa20674c 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -9,10 +9,17 @@ export function getPrerenderDefault(config: AstroConfig) { /** * Returns the correct output directory of the SSR build based on the configuration */ -export function getOutputDirectory(settings: AstroSettings): URL { +export function getServerOutputDirectory(settings: AstroSettings): URL { if (settings.buildOutput === 'server') { return settings.config.build.server; } else { return getOutDirWithinCwd(settings.config.outDir); } } + +/** + * Returns the correct output directory of the client build based on the configuration + */ +export function getClientOutputDirectory(settings: AstroSettings): URL { + return settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir; +} diff --git a/packages/astro/test/fixtures/fonts/astro.config.mjs b/packages/astro/test/fixtures/fonts/astro.config.mjs new file mode 100644 index 000000000000..882e6515a67e --- /dev/null +++ b/packages/astro/test/fixtures/fonts/astro.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/fonts/package.json b/packages/astro/test/fixtures/fonts/package.json new file mode 100644 index 000000000000..873d15586ea9 --- /dev/null +++ b/packages/astro/test/fixtures/fonts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/fonts", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/fonts/src/pages/index.astro b/packages/astro/test/fixtures/fonts/src/pages/index.astro new file mode 100644 index 000000000000..386253db1d28 --- /dev/null +++ b/packages/astro/test/fixtures/fonts/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +import { Fonts } from 'astro:assets' +--- + + \ No newline at end of file diff --git a/packages/astro/test/fixtures/fonts/src/pages/preload.astro b/packages/astro/test/fixtures/fonts/src/pages/preload.astro new file mode 100644 index 000000000000..dab372339dce --- /dev/null +++ b/packages/astro/test/fixtures/fonts/src/pages/preload.astro @@ -0,0 +1,5 @@ +--- +import { Fonts } from 'astro:assets' +--- + + \ No newline at end of file diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js new file mode 100644 index 000000000000..fb7194634178 --- /dev/null +++ b/packages/astro/test/fonts.test.js @@ -0,0 +1,72 @@ +// @ts-check +import { after, before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import assert from 'node:assert/strict'; + +describe('astro:fonts', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').DevServer} */ + let devServer; + + describe(' component', () => { + // TODO: remove once fonts are stabilized + describe('Fonts are not enabled', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/fonts/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Throws an error if fonts are not enabled', async () => { + const res = await fixture.fetch('/'); + const body = await res.text(); + assert.equal( + body.includes('