Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cyan-games-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Updates unifont to latest and adds support for `fetch` options from remote providers when using the experimental fonts API
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
"tinyglobby": "^0.2.12",
"tsconfck": "^3.1.5",
"ultrahtml": "^1.6.0",
"unifont": "~0.2.0",
"unifont": "~0.4.0",
"unist-util-visit": "^5.0.0",
"unstorage": "^1.15.0",
"vfile": "^6.0.3",
Expand Down
33 changes: 20 additions & 13 deletions packages/astro/src/assets/fonts/definitions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type * as unifont from 'unifont';
import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js';
/* eslint-disable @typescript-eslint/no-empty-object-type */
import type { AstroFontProvider, FontType, PreloadData, ResolvedFontProvider } from './types.js';
import type {
AstroFontProvider,
FontFileData,
FontType,
PreloadData,
ResolvedFontProvider,
} from './types.js';
import type { FontFaceMetrics, GenericFallbackName } from './types.js';

export interface Hasher {
Expand Down Expand Up @@ -43,24 +49,25 @@ export interface ErrorHandler {
}

export interface UrlProxy {
proxy: (input: {
url: string;
collectPreload: boolean;
data: Partial<unifont.FontFaceData>;
}) => string;
proxy: (
input: Pick<FontFileData, 'url' | 'init'> & {
collectPreload: boolean;
data: Partial<unifont.FontFaceData>;
},
) => string;
}

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

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

export type CssProperties = Record<string, string | undefined>;
Expand All @@ -87,7 +94,7 @@ export interface SystemFallbacksProvider {
}

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

export interface FontTypeExtractor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,14 @@ export function createDataCollector({
saveFontData,
}: Omit<CreateUrlProxyParams, 'local'>): DataCollector {
return {
collect({ originalUrl, hash, preload, data }) {
collect({ hash, url, init, preload, data }) {
if (!hasUrl(hash)) {
saveUrl(hash, originalUrl);
saveUrl({ hash, url, init });
if (preload) {
savePreload(preload);
}
}
saveFontData({
hash,
url: originalUrl,
data,
});
saveFontData({ hash, url, data, init });
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,17 @@ export function createCachedFontFetcher({
}: {
storage: Storage;
errorHandler: ErrorHandler;
fetch: (url: string) => Promise<Response>;
fetch: (url: string, init?: RequestInit) => Promise<Response>;
readFile: (url: string) => Promise<Buffer>;
}): FontFetcher {
return {
async fetch(hash, url) {
async fetch({ hash, url, init }) {
return await cache(storage, hash, async () => {
try {
if (isAbsolute(url)) {
return await readFile(url);
}
// TODO: find a way to pass headers
// https://github.com/unjs/unifont/issues/143
const response = await fetch(url);
const response = await fetch(url, init ?? undefined);
if (!response.ok) {
throw new Error(`Response was not successful, received status code ${response.status}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export function createCapsizeFontMetricsResolver({
const cache: Record<string, FontFaceMetrics | null> = {};

return {
async getMetrics(name, { hash, url }) {
cache[name] ??= filterRequiredMetrics(await fromBuffer(await fontFetcher.fetch(hash, url)));
async getMetrics(name, input) {
cache[name] ??= filterRequiredMetrics(await fromBuffer(await fontFetcher.fetch(input)));
return cache[name];
},
// Source: https://github.com/unjs/fontaine/blob/f00f84032c5d5da72c8798eae4cd68d3ddfbf340/src/css.ts#L170
Expand Down
5 changes: 3 additions & 2 deletions packages/astro/src/assets/fonts/implementations/url-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ export function createUrlProxy({
fontTypeExtractor: FontTypeExtractor;
}): UrlProxy {
return {
proxy({ url: originalUrl, data, collectPreload }) {
proxy({ url: originalUrl, data, collectPreload, init }) {
const type = fontTypeExtractor.extract(originalUrl);
const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`;
const url = base + hash;

dataCollector.collect({
originalUrl,
url: originalUrl,
hash,
preload: collectPreload ? { url, type } : null,
data,
init,
});

return url;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function normalizeRemoteFontFaces({
weight: font.weight,
style: font.style,
},
init: font.meta?.init ?? null,
}),
};
index++;
Expand Down
14 changes: 6 additions & 8 deletions packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type * as unifont from 'unifont';
import type { FontMetricsResolver, SystemFallbacksProvider } from '../definitions.js';
import type { ResolvedFontFamily } from '../types.js';
import type * as unifont from 'unifont';
import type { FontFileData, ResolvedFontFamily } from '../types.js';
import { isGenericFontFamily, unifontFontFaceDataToProperties } from '../utils.js';

export interface CollectedFontForMetrics {
hash: string;
url: string;
export interface CollectedFontForMetrics extends FontFileData {
data: Partial<unifont.FontFaceData>;
}

Expand Down Expand Up @@ -64,14 +62,14 @@ export async function optimizeFallbacks({
let css = '';

for (const { font, name } of localFontsMappings) {
for (const { hash, url, data } of collectedFonts) {
for (const collected of collectedFonts) {
// We generate a fallback for each font collected, which is per weight and style
css += fontMetricsResolver.generateFontFace({
metrics: await fontMetricsResolver.getMetrics(family.name, { hash, url, data }),
metrics: await fontMetricsResolver.getMetrics(family.name, collected),
fallbackMetrics: systemFallbacksProvider.getMetricsForLocalFont(font),
font,
name,
properties: unifontFontFaceDataToProperties(data),
properties: unifontFontFaceDataToProperties(collected.data),
});
}
}
Expand Down
28 changes: 19 additions & 9 deletions packages/astro/src/assets/fonts/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ import { normalizeRemoteFontFaces } from './logic/normalize-remote-font-faces.js
import { type CollectedFontForMetrics, optimizeFallbacks } from './logic/optimize-fallbacks.js';
import { resolveFamilies } from './logic/resolve-families.js';
import { resolveLocalFont } from './providers/local.js';
import type { CreateUrlProxyParams, Defaults, FontFamily, PreloadData } from './types.js';
import type {
ConsumableMap,
CreateUrlProxyParams,
Defaults,
FontFamily,
FontFileDataMap,
PreloadData,
} from './types.js';
import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js';

/**
Expand Down Expand Up @@ -61,7 +68,10 @@ export async function orchestrate({
fontTypeExtractor: FontTypeExtractor;
createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy;
defaults: Defaults;
}) {
}): Promise<{
fontFileDataMap: FontFileDataMap;
consumableMap: ConsumableMap;
}> {
let resolvedFamilies = await resolveFamilies({
families,
hasher,
Expand All @@ -84,11 +94,11 @@ export async function orchestrate({
* Holds associations of hash and original font file URLs, so they can be
* downloaded whenever the hash is requested.
*/
const hashToUrlMap = new Map<string, string>();
const fontFileDataMap: FontFileDataMap = new Map();
/**
* Holds associations of CSS variables and preloadData/css to be passed to the virtual module.
*/
const resolvedMap = new Map<string, { preloadData: Array<PreloadData>; css: string }>();
const consumableMap: ConsumableMap = new Map();

for (const family of resolvedFamilies) {
const preloadData: Array<PreloadData> = [];
Expand All @@ -106,9 +116,9 @@ export async function orchestrate({
*/
const urlProxy = createUrlProxy({
local: family.provider === LOCAL_PROVIDER_NAME,
hasUrl: (hash) => hashToUrlMap.has(hash),
saveUrl: (hash, url) => {
hashToUrlMap.set(hash, url);
hasUrl: (hash) => fontFileDataMap.has(hash),
saveUrl: ({ hash, url, init }) => {
fontFileDataMap.set(hash, { url, init });
},
savePreload: (preload) => {
preloadData.push(preload);
Expand Down Expand Up @@ -195,8 +205,8 @@ export async function orchestrate({

css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues);

resolvedMap.set(family.cssVariable, { preloadData, css });
consumableMap.set(family.cssVariable, { preloadData, css });
}

return { hashToUrlMap, resolvedMap };
return { fontFileDataMap, consumableMap };
}
1 change: 1 addition & 0 deletions packages/astro/src/assets/fonts/providers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function resolveLocalFont({ family, urlProxy, fontTypeExtractor }: Option
weight: variant.weight,
style: variant.style,
},
init: null
}),
format: FONT_FORMAT_MAP[fontTypeExtractor.extract(source.url)],
tech: source.tech,
Expand Down
19 changes: 18 additions & 1 deletion packages/astro/src/assets/fonts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,27 @@ export type Defaults = Partial<
>
>;

export interface FontFileData {
hash: string;
url: string;
init: RequestInit | null;
}

export interface CreateUrlProxyParams {
local: boolean;
hasUrl: (hash: string) => boolean;
saveUrl: (hash: string, url: string) => void;
saveUrl: (input: FontFileData) => void;
savePreload: (preload: PreloadData) => void;
saveFontData: (collected: CollectedFontForMetrics) => void;
}

/**
* Holds associations of hash and original font file URLs, so they can be
* downloaded whenever the hash is requested.
*/
export type FontFileDataMap = Map<FontFileData['hash'], Pick<FontFileData, 'url' | 'init'>>;

/**
* Holds associations of CSS variables and preloadData/css to be passed to the virtual module.
*/
export type ConsumableMap = Map<string, { preloadData: Array<PreloadData>; css: string }>;
35 changes: 17 additions & 18 deletions packages/astro/src/assets/fonts/vite-plugin-fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
} from './implementations/url-proxy-content-resolver.js';
import { createUrlProxy } from './implementations/url-proxy.js';
import { orchestrate } from './orchestrate.js';
import type { PreloadData } from './types.js';
import type { ConsumableMap, FontFileDataMap } from './types.js';

interface Options {
settings: AstroSettings;
Expand Down Expand Up @@ -79,18 +79,15 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
// to trailingSlash: never)
const baseUrl = removeTrailingForwardSlash(settings.config.base) + URL_PREFIX;

let resolvedMap: Map<string, { preloadData: Array<PreloadData>; css: string }> | 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<string, string> | null = null;
let fontFileDataMap: FontFileDataMap | null = null;
let consumableMap: ConsumableMap | null = null;
let isBuild: boolean;
let fontFetcher: FontFetcher | null = null;
let fontTypeExtractor: FontTypeExtractor | null = null;

const cleanup = () => {
resolvedMap = null;
hashToUrlMap = null;
consumableMap = null;
fontFileDataMap = null;
fontFetcher = null;
};

Expand Down Expand Up @@ -163,8 +160,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
});
// We initialize shared variables here and reset them in buildEnd
// to avoid locking memory
hashToUrlMap = res.hashToUrlMap;
resolvedMap = res.resolvedMap;
fontFileDataMap = res.fontFileDataMap;
consumableMap = res.consumableMap;
}

return {
Expand All @@ -190,7 +187,9 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
});
// The map is always defined at this point. Its values contains urls from remote providers
// as well as local paths for the local provider. We filter them to only keep the filepaths
const localPaths = [...hashToUrlMap!.values()].filter((url) => isAbsolute(url));
const localPaths = [...fontFileDataMap!.values()]
.filter(({ url }) => isAbsolute(url))
.map((v) => v.url);
server.watcher.on('change', (path) => {
if (localPaths.includes(path)) {
logger.info('assets', 'Font file updated');
Expand All @@ -214,8 +213,8 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
return next();
}
const hash = req.url.slice(1);
const url = hashToUrlMap?.get(hash);
if (!url) {
const associatedData = fontFileDataMap?.get(hash);
if (!associatedData) {
return next();
}
// We don't want the request to be cached in dev because we cache it already internally,
Expand All @@ -228,7 +227,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
// Storage 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 data = await fontFetcher!.fetch(hash, url);
const data = await fontFetcher!.fetch({ hash, ...associatedData });

res.setHeader('Content-Length', data.length);
res.setHeader('Content-Type', `font/${fontTypeExtractor!.extract(hash)}`);
Expand All @@ -255,7 +254,7 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
return {
code: `export const fontsData = new Map(${JSON.stringify(Array.from(resolvedMap?.entries() ?? []))})`,
code: `export const fontsData = new Map(${JSON.stringify(Array.from(consumableMap?.entries() ?? []))})`,
};
}
},
Expand All @@ -273,11 +272,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
} catch (cause) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause });
}
if (hashToUrlMap) {
if (fontFileDataMap) {
logger.info('assets', 'Copying fonts...');
await Promise.all(
Array.from(hashToUrlMap.entries()).map(async ([hash, url]) => {
const data = await fontFetcher!.fetch(hash, url);
Array.from(fontFileDataMap.entries()).map(async ([hash, associatedData]) => {
const data = await fontFetcher!.fetch({ hash, ...associatedData });
try {
writeFileSync(new URL(hash, fontsDir), data);
} catch (cause) {
Expand Down
Loading