diff --git a/.changeset/cruel-pants-lead.md b/.changeset/cruel-pants-lead.md new file mode 100644 index 000000000000..931fd1beb2f3 --- /dev/null +++ b/.changeset/cruel-pants-lead.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improves handling of font URLs not ending with a file extension when using the experimental fonts API diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index bc7382f254fb..09beec38245a 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -1,4 +1,4 @@ -import type { Defaults } from './types.js'; +import type { Defaults, FontType } from './types.js'; export const LOCAL_PROVIDER_NAME = 'local'; @@ -19,13 +19,14 @@ export const URL_PREFIX = '/_astro/fonts/'; export const CACHE_DIR = './fonts/'; export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'] as const; -export const FONT_FORMAT_MAP: Record<(typeof FONT_TYPES)[number], string> = { - woff2: 'woff2', - woff: 'woff', - otf: 'opentype', - ttf: 'truetype', - eot: 'embedded-opentype', -}; + +export const FONT_FORMATS: Array<{ type: FontType; format: string }> = [ + { type: 'woff2', format: 'woff2' }, + { type: 'woff', format: 'woff' }, + { type: 'otf', format: 'opentype' }, + { type: 'ttf', format: 'truetype' }, + { type: 'eot', format: 'embedded-opentype' }, +]; export const GENERIC_FALLBACK_NAMES = [ 'serif', diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts index 46c430979305..52ad80aa09b8 100644 --- a/packages/astro/src/assets/fonts/definitions.ts +++ b/packages/astro/src/assets/fonts/definitions.ts @@ -53,6 +53,7 @@ export interface ErrorHandler { export interface UrlProxy { proxy: ( input: Pick & { + type: FontType; collectPreload: boolean; data: Partial; }, diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy.ts b/packages/astro/src/assets/fonts/implementations/url-proxy.ts index 424375949987..a12e7b986f76 100644 --- a/packages/astro/src/assets/fonts/implementations/url-proxy.ts +++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts @@ -1,27 +1,18 @@ -import type { - DataCollector, - FontTypeExtractor, - Hasher, - UrlProxy, - UrlProxyContentResolver, -} from '../definitions.js'; +import type { DataCollector, Hasher, UrlProxy, UrlProxyContentResolver } from '../definitions.js'; export function createUrlProxy({ base, contentResolver, hasher, dataCollector, - fontTypeExtractor, }: { base: string; contentResolver: UrlProxyContentResolver; hasher: Hasher; dataCollector: DataCollector; - fontTypeExtractor: FontTypeExtractor; }): UrlProxy { return { - proxy({ url: originalUrl, data, collectPreload, init }) { - const type = fontTypeExtractor.extract(originalUrl); + proxy({ url: originalUrl, type, data, collectPreload, init }) { const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`; const url = base + hash; diff --git a/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts index 80288feaa310..959cbd07e57c 100644 --- a/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts +++ b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts @@ -1,12 +1,15 @@ import type * as unifont from 'unifont'; -import type { UrlProxy } from '../definitions.js'; +import type { FontTypeExtractor, UrlProxy } from '../definitions.js'; +import { FONT_FORMATS } from '../constants.js'; export function normalizeRemoteFontFaces({ fonts, urlProxy, + fontTypeExtractor, }: { fonts: Array; urlProxy: UrlProxy; + fontTypeExtractor: FontTypeExtractor; }): Array { return ( fonts @@ -31,6 +34,9 @@ export function normalizeRemoteFontFaces({ originalURL: url, url: urlProxy.proxy({ url, + type: + FONT_FORMATS.find((e) => e.format === source.format)?.type ?? + fontTypeExtractor.extract(source.url), // We only collect the first URL to avoid preloading fallback sources (eg. we only // preload woff2 if woff is available) collectPreload: index === 0, diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts index 21b64a154a76..5f799dc4ef03 100644 --- a/packages/astro/src/assets/fonts/orchestrate.ts +++ b/packages/astro/src/assets/fonts/orchestrate.ts @@ -179,7 +179,7 @@ export async function orchestrate({ ); } // The data returned by the remote provider contains original URLs. We proxy them. - fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy }); + fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy, fontTypeExtractor }); } for (const data of fonts) { diff --git a/packages/astro/src/assets/fonts/providers/local.ts b/packages/astro/src/assets/fonts/providers/local.ts index af8a04e2522d..63fdf3d65c7c 100644 --- a/packages/astro/src/assets/fonts/providers/local.ts +++ b/packages/astro/src/assets/fonts/providers/local.ts @@ -1,5 +1,5 @@ import type * as unifont from 'unifont'; -import { FONT_FORMAT_MAP } from '../constants.js'; +import { FONT_FORMATS } from '../constants.js'; import type { FontFileReader, FontTypeExtractor, UrlProxy } from '../definitions.js'; import type { ResolvedLocalFontFamily } from '../types.js'; @@ -44,10 +44,13 @@ export function resolveLocalFont({ if (variant.style === undefined) data.style = result.style; } + const type = fontTypeExtractor.extract(source.url); + return { originalURL: source.url, url: urlProxy.proxy({ url: source.url, + type, // We only use the first source for preloading. For example if woff2 and woff // are available, we only keep woff2. collectPreload: index === 0, @@ -57,7 +60,7 @@ export function resolveLocalFont({ }, init: null, }), - format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)], + format: FONT_FORMATS.find((e) => e.type === type)?.format, tech: source.tech, }; }); diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 9465ee0caec5..44ebcd6f012e 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -157,7 +157,6 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin { contentResolver, hasher, dataCollector, - fontTypeExtractor: fontTypeExtractor!, }); }, defaults: DEFAULTS, diff --git a/packages/astro/test/units/assets/fonts/logic.test.js b/packages/astro/test/units/assets/fonts/logic.test.js index c99a1598efae..5a7a09f672a5 100644 --- a/packages/astro/test/units/assets/fonts/logic.test.js +++ b/packages/astro/test/units/assets/fonts/logic.test.js @@ -6,7 +6,13 @@ import { extractUnifontProviders } from '../../../../dist/assets/fonts/logic/ext import { normalizeRemoteFontFaces } from '../../../../dist/assets/fonts/logic/normalize-remote-font-faces.js'; import { optimizeFallbacks } from '../../../../dist/assets/fonts/logic/optimize-fallbacks.js'; import { resolveFamily } from '../../../../dist/assets/fonts/logic/resolve-families.js'; -import { createSpyUrlProxy, fakeFontMetricsResolver, fakeHasher } from './utils.js'; +import { + createSpyUrlProxy, + fakeFontMetricsResolver, + fakeHasher, + simpleErrorHandler, +} from './utils.js'; +import { createFontTypeExtractor } from '../../../../dist/assets/fonts/implementations/font-type-extractor.js'; describe('fonts logic', () => { describe('resolveFamily()', () => { @@ -336,7 +342,14 @@ describe('fonts logic', () => { describe('normalizeRemoteFontFaces()', () => { it('filters font data based on priority', () => { const { urlProxy } = createSpyUrlProxy(); - assert.equal(normalizeRemoteFontFaces({ fonts: [], urlProxy }).length, 0); + assert.equal( + normalizeRemoteFontFaces({ + fonts: [], + urlProxy, + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), + }).length, + 0, + ); assert.equal( normalizeRemoteFontFaces({ fonts: [ @@ -362,6 +375,7 @@ describe('fonts logic', () => { }, ], urlProxy, + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), }).length, 4, ); @@ -375,30 +389,37 @@ describe('fonts logic', () => { { weight: '400', style: 'normal', - src: [{ url: '/' }, { url: '/ignored' }], + src: [ + { url: '/', format: 'woff2' }, + { url: '/ignored', format: 'woff2' }, + ], }, { weight: '500', style: 'normal', - src: [{ url: '/2' }], + src: [{ url: '/2', format: 'woff2' }], }, ], + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), }); assert.deepStrictEqual(collected, [ { url: '/', + type: 'woff2', collectPreload: true, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/ignored', + type: 'woff2', collectPreload: false, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/2', + type: 'woff2', collectPreload: true, data: { weight: '500', style: 'normal' }, init: null, @@ -414,36 +435,49 @@ describe('fonts logic', () => { { weight: '400', style: 'normal', - src: [{ name: 'Arial' }, { url: '/' }, { url: '/ignored' }], + src: [ + { name: 'Arial' }, + { url: '/', format: 'woff2' }, + { url: '/ignored', format: 'woff2' }, + ], }, { weight: '500', style: 'normal', - src: [{ url: '/2' }, { name: 'Foo' }, { url: '/also-ignored' }], + src: [ + { url: '/2', format: 'woff2' }, + { name: 'Foo' }, + { url: '/also-ignored', format: 'woff2' }, + ], }, ], + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), }); assert.deepStrictEqual(collected, [ { url: '/', + type: 'woff2', collectPreload: true, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/ignored', + type: 'woff2', collectPreload: false, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/2', + type: 'woff2', collectPreload: true, data: { weight: '500', style: 'normal' }, init: null, }, { url: '/also-ignored', + type: 'woff2', collectPreload: false, data: { weight: '500', style: 'normal' }, init: null, @@ -451,7 +485,7 @@ describe('fonts logic', () => { ]); }); - it('turns relative protocols into https', () => { + it('computes type and format correctly', () => { const { collected, urlProxy } = createSpyUrlProxy(); const fonts = normalizeRemoteFontFaces({ urlProxy, @@ -459,40 +493,128 @@ describe('fonts logic', () => { { weight: '400', style: 'normal', - src: [{ url: '//example.com/font.woff2' }, { url: 'http://example.com/font.woff' }], + src: [{ name: 'Arial' }, { url: '/', format: 'woff2' }, { url: '/ignored.ttf' }], + }, + { + weight: '500', + style: 'normal', + src: [{ url: '/2', format: 'woff2' }, { name: 'Foo' }, { url: '/also-ignored.ttf' }], }, ], + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), }); + assert.deepStrictEqual(fonts, [ + { + src: [ + { + name: 'Arial', + }, + { + format: 'woff2', + originalURL: '/', + url: '/', + }, + { + originalURL: '/ignored.ttf', + url: '/ignored.ttf', + }, + ], + style: 'normal', + weight: '400', + }, + { + src: [ + { + format: 'woff2', + originalURL: '/2', + url: '/2', + }, + { + name: 'Foo', + }, + { + originalURL: '/also-ignored.ttf', + url: '/also-ignored.ttf', + }, + ], + style: 'normal', + weight: '500', + }, + ]); + assert.deepStrictEqual(collected, [ + { + url: '/', + type: 'woff2', + collectPreload: true, + data: { weight: '400', style: 'normal' }, + init: null, + }, + { + url: '/ignored.ttf', + type: 'ttf', + collectPreload: false, + data: { weight: '400', style: 'normal' }, + init: null, + }, + { + url: '/2', + type: 'woff2', + collectPreload: true, + data: { weight: '500', style: 'normal' }, + init: null, + }, + { + url: '/also-ignored.ttf', + type: 'ttf', + collectPreload: false, + data: { weight: '500', style: 'normal' }, + init: null, + }, + ]); + }); - assert.deepStrictEqual( - fonts, - [ + it('turns relative protocols into https', () => { + const { collected, urlProxy } = createSpyUrlProxy(); + const fonts = normalizeRemoteFontFaces({ + urlProxy, + fonts: [ { - src: [ - { - originalURL: 'https://example.com/font.woff2', - url: 'https://example.com/font.woff2', - }, - { - originalURL: 'http://example.com/font.woff', - url: 'http://example.com/font.woff', - }, - ], - style: 'normal', weight: '400', + style: 'normal', + src: [{ url: '//example.com/font.woff2' }, { url: 'http://example.com/font.woff' }], }, ], - ); + fontTypeExtractor: createFontTypeExtractor({ errorHandler: simpleErrorHandler }), + }); + + assert.deepStrictEqual(fonts, [ + { + src: [ + { + originalURL: 'https://example.com/font.woff2', + url: 'https://example.com/font.woff2', + }, + { + originalURL: 'http://example.com/font.woff', + url: 'http://example.com/font.woff', + }, + ], + style: 'normal', + weight: '400', + }, + ]); assert.deepStrictEqual(collected, [ { url: 'https://example.com/font.woff2', collectPreload: true, + type: 'woff2', data: { weight: '400', style: 'normal' }, init: null, }, { url: 'http://example.com/font.woff', collectPreload: false, + type: 'woff', data: { weight: '400', style: 'normal' }, init: null, }, diff --git a/packages/astro/test/units/assets/fonts/orchestrate.test.js b/packages/astro/test/units/assets/fonts/orchestrate.test.js index 55610c9a937c..c50cf5190d8b 100644 --- a/packages/astro/test/units/assets/fonts/orchestrate.test.js +++ b/packages/astro/test/units/assets/fonts/orchestrate.test.js @@ -66,7 +66,6 @@ describe('fonts orchestrate()', () => { contentResolver, hasher, dataCollector, - fontTypeExtractor, }); }, defaults: DEFAULTS, @@ -167,7 +166,6 @@ describe('fonts orchestrate()', () => { contentResolver, hasher, dataCollector, - fontTypeExtractor, }); }, defaults: DEFAULTS, diff --git a/packages/astro/test/units/assets/fonts/providers.test.js b/packages/astro/test/units/assets/fonts/providers.test.js index 2c6be24cad43..5c8398fd36a1 100644 --- a/packages/astro/test/units/assets/fonts/providers.test.js +++ b/packages/astro/test/units/assets/fonts/providers.test.js @@ -89,18 +89,21 @@ describe('fonts providers', () => { assert.deepStrictEqual(collected, [ { url: '/test.woff2', + type: 'woff2', collectPreload: true, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/ignored.woff', + type: 'woff', collectPreload: false, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/2.woff2', + type: 'woff2', collectPreload: true, data: { weight: '500', style: 'normal' }, init: null, @@ -136,24 +139,28 @@ describe('fonts providers', () => { assert.deepStrictEqual(collected, [ { url: '/test.woff2', + type: 'woff2', collectPreload: true, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/ignored.woff', + type: 'woff', collectPreload: false, data: { weight: '400', style: 'normal' }, init: null, }, { url: '/2.woff2', + type: 'woff2', collectPreload: true, data: { weight: '500', style: 'normal' }, init: null, }, { url: '/also-ignored.woff', + type: 'woff', collectPreload: false, data: { weight: '500', style: 'normal' }, init: null, @@ -161,6 +168,53 @@ describe('fonts providers', () => { ]); }); + it('computes the format correctly', () => { + const { urlProxy } = createSpyUrlProxy(); + const { fonts } = resolveLocalFont({ + urlProxy, + fontTypeExtractor, + fontFileReader: createFontaceFontFileReader({ errorHandler: simpleErrorHandler }), + family: { + name: 'Test', + nameWithHash: 'Test-xxx', + cssVariable: '--test', + provider: 'local', + variants: [ + { + weight: '400', + style: 'normal', + src: [{ url: '/test.woff2' }, { url: '/ignored.ttf' }], + }, + ], + }, + }); + assert.deepStrictEqual(fonts, [ + { + display: undefined, + featureSettings: undefined, + src: [ + { + format: 'woff2', + originalURL: '/test.woff2', + tech: undefined, + url: '/test.woff2', + }, + { + format: 'truetype', + originalURL: '/ignored.ttf', + tech: undefined, + url: '/ignored.ttf', + }, + ], + stretch: undefined, + style: 'normal', + unicodeRange: undefined, + variationSettings: undefined, + weight: '400', + }, + ]); + }); + describe('properties inference', () => { it('infers properties correctly', async () => { const { collected, urlProxy } = createSpyUrlProxy(); @@ -211,6 +265,7 @@ describe('fonts providers', () => { { url: '/test.woff2', collectPreload: true, + type: 'woff2', data: { weight: '300', style: 'italic' }, init: null, }, @@ -269,6 +324,7 @@ describe('fonts providers', () => { { url: '/test.woff2', collectPreload: true, + type: 'woff2', data: { weight: '300', style: 'normal' }, init: null, },