aboutsummaryrefslogtreecommitdiff
path: root/packages/astro/src/assets
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src/assets')
-rw-r--r--packages/astro/src/assets/README.md3
-rw-r--r--packages/astro/src/assets/build/generate.ts368
-rw-r--r--packages/astro/src/assets/build/remote.ts108
-rw-r--r--packages/astro/src/assets/consts.ts37
-rw-r--r--packages/astro/src/assets/endpoint/config.ts57
-rw-r--r--packages/astro/src/assets/endpoint/generic.ts79
-rw-r--r--packages/astro/src/assets/endpoint/node.ts134
-rw-r--r--packages/astro/src/assets/fonts/README.md20
-rw-r--r--packages/astro/src/assets/fonts/config.ts178
-rw-r--r--packages/astro/src/assets/fonts/constants.ts46
-rw-r--r--packages/astro/src/assets/fonts/definitions.ts116
-rw-r--r--packages/astro/src/assets/fonts/implementations/css-renderer.ts50
-rw-r--r--packages/astro/src/assets/fonts/implementations/data-collector.ts21
-rw-r--r--packages/astro/src/assets/fonts/implementations/error-handler.ts42
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-fetcher.ts39
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-file-reader.ts26
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts73
-rw-r--r--packages/astro/src/assets/fonts/implementations/font-type-extractor.ts21
-rw-r--r--packages/astro/src/assets/fonts/implementations/hasher.ts13
-rw-r--r--packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts22
-rw-r--r--packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts20
-rw-r--r--packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts62
-rw-r--r--packages/astro/src/assets/fonts/implementations/storage.ts12
-rw-r--r--packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts79
-rw-r--r--packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts30
-rw-r--r--packages/astro/src/assets/fonts/implementations/url-proxy.ts36
-rw-r--r--packages/astro/src/assets/fonts/implementations/url-resolver.ts27
-rw-r--r--packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts46
-rw-r--r--packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts56
-rw-r--r--packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts78
-rw-r--r--packages/astro/src/assets/fonts/logic/resolve-families.ts99
-rw-r--r--packages/astro/src/assets/fonts/orchestrate.ts227
-rw-r--r--packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts4
-rw-r--r--packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts3
-rw-r--r--packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts3
-rw-r--r--packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts3
-rw-r--r--packages/astro/src/assets/fonts/providers/entrypoints/google.ts4
-rw-r--r--packages/astro/src/assets/fonts/providers/index.ts60
-rw-r--r--packages/astro/src/assets/fonts/providers/local.ts70
-rw-r--r--packages/astro/src/assets/fonts/sync.ts18
-rw-r--r--packages/astro/src/assets/fonts/types.ts106
-rw-r--r--packages/astro/src/assets/fonts/utils.ts119
-rw-r--r--packages/astro/src/assets/fonts/vite-plugin-fonts.ts305
-rw-r--r--packages/astro/src/assets/index.ts3
-rw-r--r--packages/astro/src/assets/internal.ts233
-rw-r--r--packages/astro/src/assets/layout.ts118
-rw-r--r--packages/astro/src/assets/runtime.ts60
-rw-r--r--packages/astro/src/assets/services/noop.ts15
-rw-r--r--packages/astro/src/assets/services/service.ts428
-rw-r--r--packages/astro/src/assets/services/sharp.ts136
-rw-r--r--packages/astro/src/assets/types.ts286
-rw-r--r--packages/astro/src/assets/utils/etag.ts45
-rw-r--r--packages/astro/src/assets/utils/getAssetsPrefix.ts12
-rw-r--r--packages/astro/src/assets/utils/imageAttributes.ts20
-rw-r--r--packages/astro/src/assets/utils/imageKind.ts37
-rw-r--r--packages/astro/src/assets/utils/index.ts28
-rw-r--r--packages/astro/src/assets/utils/metadata.ts42
-rw-r--r--packages/astro/src/assets/utils/node/emitAsset.ts162
-rw-r--r--packages/astro/src/assets/utils/proxy.ts23
-rw-r--r--packages/astro/src/assets/utils/queryParams.ts28
-rw-r--r--packages/astro/src/assets/utils/remotePattern.ts13
-rw-r--r--packages/astro/src/assets/utils/remoteProbe.ts63
-rw-r--r--packages/astro/src/assets/utils/resolveImports.ts44
-rw-r--r--packages/astro/src/assets/utils/svg.ts31
-rw-r--r--packages/astro/src/assets/utils/transformToPath.ts69
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/LICENSE9
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/README.md3
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/detector.ts25
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/lookup.ts26
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/cur.ts17
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/dds.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/gif.ts12
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/heif.ts55
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/icns.ts113
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/ico.ts75
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/index.ts44
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/interface.ts15
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts12
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts23
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts162
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts19
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/png.ts37
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts80
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/psd.ts11
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/svg.ts109
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/tga.ts15
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts93
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/utils.ts84
-rw-r--r--packages/astro/src/assets/utils/vendor/image-size/types/webp.ts68
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts266
91 files changed, 6211 insertions, 0 deletions
diff --git a/packages/astro/src/assets/README.md b/packages/astro/src/assets/README.md
new file mode 100644
index 000000000..9de1c5eb4
--- /dev/null
+++ b/packages/astro/src/assets/README.md
@@ -0,0 +1,3 @@
+# assets
+
+This directory powers the Assets story in Astro. Notably, it contains all the code related to optimizing images and serving them in the different modes Astro can run in (SSG, SSR, dev, build etc).
diff --git a/packages/astro/src/assets/build/generate.ts b/packages/astro/src/assets/build/generate.ts
new file mode 100644
index 000000000..028ce30f6
--- /dev/null
+++ b/packages/astro/src/assets/build/generate.ts
@@ -0,0 +1,368 @@
+import fs, { readFileSync } from 'node:fs';
+import { basename } from 'node:path/posix';
+import { dim, green } from 'kleur/colors';
+import { getOutDirWithinCwd } from '../../core/build/common.js';
+import type { BuildPipeline } from '../../core/build/pipeline.js';
+import { getTimeStat } from '../../core/build/util.js';
+import { AstroError } from '../../core/errors/errors.js';
+import { AstroErrorData } from '../../core/errors/index.js';
+import type { Logger } from '../../core/logger/core.js';
+import { isRemotePath, removeLeadingForwardSlash } from '../../core/path.js';
+import type { MapValue } from '../../type-utils.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { getConfiguredImageService } from '../internal.js';
+import type { LocalImageService } from '../services/service.js';
+import type { AssetsGlobalStaticImagesList, ImageMetadata, ImageTransform } from '../types.js';
+import { isESMImportedImage } from '../utils/imageKind.js';
+import { type RemoteCacheEntry, loadRemoteImage, revalidateRemoteImage } from './remote.js';
+
+interface GenerationDataUncached {
+ cached: 'miss';
+ weight: {
+ before: number;
+ after: number;
+ };
+}
+
+interface GenerationDataCached {
+ cached: 'revalidated' | 'hit';
+}
+
+type GenerationData = GenerationDataUncached | GenerationDataCached;
+
+type AssetEnv = {
+ logger: Logger;
+ isSSR: boolean;
+ count: { total: number; current: number };
+ useCache: boolean;
+ assetsCacheDir: URL;
+ serverRoot: URL;
+ clientRoot: URL;
+ imageConfig: AstroConfig['image'];
+ assetsFolder: AstroConfig['build']['assets'];
+};
+
+type ImageData = {
+ data: Uint8Array;
+ expires: number;
+ etag?: string;
+ lastModified?: string;
+};
+
+export async function prepareAssetsGenerationEnv(
+ pipeline: BuildPipeline,
+ totalCount: number,
+): Promise<AssetEnv> {
+ const { config, logger, settings } = pipeline;
+ let useCache = true;
+ const assetsCacheDir = new URL('assets/', config.cacheDir);
+ const count = { total: totalCount, current: 1 };
+
+ // Ensure that the cache directory exists
+ try {
+ await fs.promises.mkdir(assetsCacheDir, { recursive: true });
+ } catch (err) {
+ logger.warn(
+ null,
+ `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${err}`,
+ );
+ useCache = false;
+ }
+
+ const isServerOutput = settings.buildOutput === 'server';
+ let serverRoot: URL, clientRoot: URL;
+ if (isServerOutput) {
+ serverRoot = config.build.server;
+ clientRoot = config.build.client;
+ } else {
+ serverRoot = getOutDirWithinCwd(config.outDir);
+ clientRoot = config.outDir;
+ }
+
+ return {
+ logger,
+ isSSR: isServerOutput,
+ count,
+ useCache,
+ assetsCacheDir,
+ serverRoot,
+ clientRoot,
+ imageConfig: config.image,
+ assetsFolder: config.build.assets,
+ };
+}
+
+function getFullImagePath(originalFilePath: string, env: AssetEnv): URL {
+ return new URL(removeLeadingForwardSlash(originalFilePath), env.serverRoot);
+}
+
+export async function generateImagesForPath(
+ originalFilePath: string,
+ transformsAndPath: MapValue<AssetsGlobalStaticImagesList>,
+ env: AssetEnv,
+) {
+ let originalImage: ImageData;
+
+ for (const [_, transform] of transformsAndPath.transforms) {
+ await generateImage(transform.finalPath, transform.transform);
+ }
+
+ // In SSR, we cannot know if an image is referenced in a server-rendered page, so we can't delete anything
+ // For instance, the same image could be referenced in both a server-rendered page and build-time-rendered page
+ if (
+ !env.isSSR &&
+ transformsAndPath.originalSrcPath &&
+ !globalThis.astroAsset.referencedImages?.has(transformsAndPath.originalSrcPath)
+ ) {
+ try {
+ if (transformsAndPath.originalSrcPath) {
+ env.logger.debug(
+ 'assets',
+ `Deleting ${originalFilePath} as it's not referenced outside of image processing.`,
+ );
+ await fs.promises.unlink(getFullImagePath(originalFilePath, env));
+ }
+ } catch {
+ /* No-op, it's okay if we fail to delete one of the file, we're not too picky. */
+ }
+ }
+
+ async function generateImage(filepath: string, options: ImageTransform) {
+ const timeStart = performance.now();
+ const generationData = await generateImageInternal(filepath, options);
+
+ const timeEnd = performance.now();
+ const timeChange = getTimeStat(timeStart, timeEnd);
+ const timeIncrease = `(+${timeChange})`;
+ const statsText =
+ generationData.cached !== 'miss'
+ ? generationData.cached === 'hit'
+ ? `(reused cache entry)`
+ : `(revalidated cache entry)`
+ : `(before: ${generationData.weight.before}kB, after: ${generationData.weight.after}kB)`;
+ const count = `(${env.count.current}/${env.count.total})`;
+ env.logger.info(
+ null,
+ ` ${green('▶')} ${filepath} ${dim(statsText)} ${dim(timeIncrease)} ${dim(count)}`,
+ );
+ env.count.current++;
+ }
+
+ async function generateImageInternal(
+ filepath: string,
+ options: ImageTransform,
+ ): Promise<GenerationData> {
+ const isLocalImage = isESMImportedImage(options.src);
+ const finalFileURL = new URL('.' + filepath, env.clientRoot);
+
+ const finalFolderURL = new URL('./', finalFileURL);
+ await fs.promises.mkdir(finalFolderURL, { recursive: true });
+
+ const cacheFile = basename(filepath);
+ const cachedFileURL = new URL(cacheFile, env.assetsCacheDir);
+
+ // For remote images, we also save a JSON file with the expiration date, etag and last-modified date from the server
+ const cacheMetaFile = cacheFile + '.json';
+ const cachedMetaFileURL = new URL(cacheMetaFile, env.assetsCacheDir);
+
+ // Check if we have a cached entry first
+ try {
+ if (isLocalImage) {
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+
+ return {
+ cached: 'hit',
+ };
+ } else {
+ const JSONData = JSON.parse(readFileSync(cachedMetaFileURL, 'utf-8')) as RemoteCacheEntry;
+
+ if (!JSONData.expires) {
+ try {
+ await fs.promises.unlink(cachedFileURL);
+ } catch {
+ /* Old caches may not have a separate image binary, no-op */
+ }
+ await fs.promises.unlink(cachedMetaFileURL);
+
+ throw new Error(
+ `Malformed cache entry for ${filepath}, cache will be regenerated for this file.`,
+ );
+ }
+
+ // Upgrade old base64 encoded asset cache to the new format
+ if (JSONData.data) {
+ const { data, ...meta } = JSONData;
+
+ await Promise.all([
+ fs.promises.writeFile(cachedFileURL, Buffer.from(data, 'base64')),
+ writeCacheMetaFile(cachedMetaFileURL, meta, env),
+ ]);
+ }
+
+ // If the cache entry is not expired, use it
+ if (JSONData.expires > Date.now()) {
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+
+ return {
+ cached: 'hit',
+ };
+ }
+
+ // Try to revalidate the cache
+ if (JSONData.etag || JSONData.lastModified) {
+ try {
+ const revalidatedData = await revalidateRemoteImage(options.src as string, {
+ etag: JSONData.etag,
+ lastModified: JSONData.lastModified,
+ });
+
+ if (revalidatedData.data.length) {
+ // Image cache was stale, update original image to avoid redownload
+ originalImage = revalidatedData;
+ } else {
+ // Freshen cache on disk
+ await writeCacheMetaFile(cachedMetaFileURL, revalidatedData, env);
+
+ await fs.promises.copyFile(
+ cachedFileURL,
+ finalFileURL,
+ fs.constants.COPYFILE_FICLONE,
+ );
+ return { cached: 'revalidated' };
+ }
+ } catch (e) {
+ // Reuse stale cache if revalidation fails
+ env.logger.warn(
+ null,
+ `An error was encountered while revalidating a cached remote asset. Proceeding with stale cache. ${e}`,
+ );
+
+ await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE);
+ return { cached: 'hit' };
+ }
+ }
+
+ await fs.promises.unlink(cachedFileURL);
+ await fs.promises.unlink(cachedMetaFileURL);
+ }
+ } catch (e: any) {
+ if (e.code !== 'ENOENT') {
+ throw new Error(`An error was encountered while reading the cache file. Error: ${e}`);
+ }
+ // If the cache file doesn't exist, just move on, and we'll generate it
+ }
+
+ // The original filepath or URL from the image transform
+ const originalImagePath = isLocalImage
+ ? (options.src as ImageMetadata).src
+ : (options.src as string);
+
+ if (!originalImage) {
+ originalImage = await loadImage(originalFilePath, env);
+ }
+
+ let resultData: Partial<ImageData> = {
+ data: undefined,
+ expires: originalImage.expires,
+ etag: originalImage.etag,
+ lastModified: originalImage.lastModified,
+ };
+
+ const imageService = (await getConfiguredImageService()) as LocalImageService;
+
+ try {
+ resultData.data = (
+ await imageService.transform(
+ originalImage.data,
+ { ...options, src: originalImagePath },
+ env.imageConfig,
+ )
+ ).data;
+ } catch (e) {
+ if (AstroError.is(e)) {
+ throw e;
+ }
+ const error = new AstroError(
+ {
+ ...AstroErrorData.CouldNotTransformImage,
+ message: AstroErrorData.CouldNotTransformImage.message(originalFilePath),
+ },
+ { cause: e },
+ );
+
+ throw error;
+ }
+
+ try {
+ // Write the cache entry
+ if (env.useCache) {
+ if (isLocalImage) {
+ await fs.promises.writeFile(cachedFileURL, resultData.data);
+ } else {
+ await Promise.all([
+ fs.promises.writeFile(cachedFileURL, resultData.data),
+ writeCacheMetaFile(cachedMetaFileURL, resultData as ImageData, env),
+ ]);
+ }
+ }
+ } catch (e) {
+ env.logger.warn(
+ null,
+ `An error was encountered while creating the cache directory. Proceeding without caching. Error: ${e}`,
+ );
+ } finally {
+ // Write the final file
+ await fs.promises.writeFile(finalFileURL, resultData.data);
+ }
+
+ return {
+ cached: 'miss',
+ weight: {
+ // Divide by 1024 to get size in kilobytes
+ before: Math.trunc(originalImage.data.byteLength / 1024),
+ after: Math.trunc(Buffer.from(resultData.data).byteLength / 1024),
+ },
+ };
+ }
+}
+
+async function writeCacheMetaFile(
+ cachedMetaFileURL: URL,
+ resultData: Omit<ImageData, 'data'>,
+ env: AssetEnv,
+) {
+ try {
+ return await fs.promises.writeFile(
+ cachedMetaFileURL,
+ JSON.stringify({
+ expires: resultData.expires,
+ etag: resultData.etag,
+ lastModified: resultData.lastModified,
+ }),
+ );
+ } catch (e) {
+ env.logger.warn(
+ null,
+ `An error was encountered while writing the cache file for a remote asset. Proceeding without caching this asset. Error: ${e}`,
+ );
+ }
+}
+
+export function getStaticImageList(): AssetsGlobalStaticImagesList {
+ if (!globalThis?.astroAsset?.staticImages) {
+ return new Map();
+ }
+
+ return globalThis.astroAsset.staticImages;
+}
+
+async function loadImage(path: string, env: AssetEnv): Promise<ImageData> {
+ if (isRemotePath(path)) {
+ return await loadRemoteImage(path);
+ }
+
+ return {
+ data: await fs.promises.readFile(getFullImagePath(path, env)),
+ expires: 0,
+ };
+}
diff --git a/packages/astro/src/assets/build/remote.ts b/packages/astro/src/assets/build/remote.ts
new file mode 100644
index 000000000..55ee9a205
--- /dev/null
+++ b/packages/astro/src/assets/build/remote.ts
@@ -0,0 +1,108 @@
+import CachePolicy from 'http-cache-semantics';
+
+export type RemoteCacheEntry = {
+ data?: string;
+ expires: number;
+ etag?: string;
+ lastModified?: string;
+};
+
+export async function loadRemoteImage(src: string) {
+ const req = new Request(src);
+ const res = await fetch(req);
+
+ if (!res.ok) {
+ throw new Error(
+ `Failed to load remote image ${src}. The request did not return a 200 OK response. (received ${res.status}))`,
+ );
+ }
+
+ // calculate an expiration date based on the response's TTL
+ const policy = new CachePolicy(webToCachePolicyRequest(req), webToCachePolicyResponse(res));
+ const expires = policy.storable() ? policy.timeToLive() : 0;
+
+ return {
+ data: Buffer.from(await res.arrayBuffer()),
+ expires: Date.now() + expires,
+ etag: res.headers.get('Etag') ?? undefined,
+ lastModified: res.headers.get('Last-Modified') ?? undefined,
+ };
+}
+
+/**
+ * Revalidate a cached remote asset using its entity-tag or modified date.
+ * Uses the [If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) and [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since)
+ * headers to check with the remote server if the cached version of a remote asset is still up to date.
+ * The remote server may respond that the cached asset is still up-to-date if the entity-tag or modification time matches (304 Not Modified), or respond with an updated asset (200 OK)
+ * @param src - url to remote asset
+ * @param revalidationData - an object containing the stored Entity-Tag of the cached asset and/or the Last Modified time
+ * @returns An ImageData object containing the asset data, a new expiry time, and the asset's etag. The data buffer will be empty if the asset was not modified.
+ */
+export async function revalidateRemoteImage(
+ src: string,
+ revalidationData: { etag?: string; lastModified?: string },
+) {
+ const headers = {
+ ...(revalidationData.etag && { 'If-None-Match': revalidationData.etag }),
+ ...(revalidationData.lastModified && { 'If-Modified-Since': revalidationData.lastModified }),
+ };
+ const req = new Request(src, { headers });
+ const res = await fetch(req);
+
+ // Asset not modified: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
+ if (!res.ok && res.status !== 304) {
+ throw new Error(
+ `Failed to revalidate cached remote image ${src}. The request did not return a 200 OK / 304 NOT MODIFIED response. (received ${res.status} ${res.statusText})`,
+ );
+ }
+
+ const data = Buffer.from(await res.arrayBuffer());
+
+ if (res.ok && !data.length) {
+ // Server did not include body but indicated cache was stale
+ return await loadRemoteImage(src);
+ }
+
+ // calculate an expiration date based on the response's TTL
+ const policy = new CachePolicy(
+ webToCachePolicyRequest(req),
+ webToCachePolicyResponse(
+ res.ok ? res : new Response(null, { status: 200, headers: res.headers }),
+ ), // 304 responses themselves are not cacheable, so just pretend to get the refreshed TTL
+ );
+ const expires = policy.storable() ? policy.timeToLive() : 0;
+
+ return {
+ data,
+ expires: Date.now() + expires,
+ // While servers should respond with the same headers as a 200 response, if they don't we should reuse the stored value
+ etag: res.headers.get('Etag') ?? (res.ok ? undefined : revalidationData.etag),
+ lastModified:
+ res.headers.get('Last-Modified') ?? (res.ok ? undefined : revalidationData.lastModified),
+ };
+}
+
+function webToCachePolicyRequest({ url, method, headers: _headers }: Request): CachePolicy.Request {
+ let headers: CachePolicy.Headers = {};
+ // Be defensive here due to a cookie header bug in node@18.14.1 + undici
+ try {
+ headers = Object.fromEntries(_headers.entries());
+ } catch {}
+ return {
+ method,
+ url,
+ headers,
+ };
+}
+
+function webToCachePolicyResponse({ status, headers: _headers }: Response): CachePolicy.Response {
+ let headers: CachePolicy.Headers = {};
+ // Be defensive here due to a cookie header bug in node@18.14.1 + undici
+ try {
+ headers = Object.fromEntries(_headers.entries());
+ } catch {}
+ return {
+ status,
+ headers,
+ };
+}
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
new file mode 100644
index 000000000..5fae641ae
--- /dev/null
+++ b/packages/astro/src/assets/consts.ts
@@ -0,0 +1,37 @@
+export const VIRTUAL_MODULE_ID = 'astro:assets';
+export const VIRTUAL_SERVICE_ID = 'virtual:image-service';
+export const VALID_INPUT_FORMATS = [
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'tiff',
+ 'webp',
+ 'gif',
+ 'svg',
+ 'avif',
+] as const;
+/**
+ * Valid formats that our base services support.
+ * Certain formats can be imported (namely SVGs) but will not be processed.
+ */
+export const VALID_SUPPORTED_FORMATS = [
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'tiff',
+ 'webp',
+ 'gif',
+ 'svg',
+ 'avif',
+] as const;
+export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
+export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
+export const DEFAULT_HASH_PROPS = [
+ 'src',
+ 'width',
+ 'height',
+ 'format',
+ 'quality',
+ 'fit',
+ 'position',
+];
diff --git a/packages/astro/src/assets/endpoint/config.ts b/packages/astro/src/assets/endpoint/config.ts
new file mode 100644
index 000000000..2deb3dd66
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/config.ts
@@ -0,0 +1,57 @@
+import {
+ removeLeadingForwardSlash,
+ removeTrailingForwardSlash,
+} from '@astrojs/internal-helpers/path';
+import { resolveInjectedRoute } from '../../core/routing/manifest/create.js';
+import { getPattern } from '../../core/routing/manifest/pattern.js';
+import type { AstroSettings, RoutesList } from '../../types/astro.js';
+import type { RouteData } from '../../types/public/internal.js';
+
+export function injectImageEndpoint(
+ settings: AstroSettings,
+ manifest: RoutesList,
+ mode: 'dev' | 'build',
+ cwd?: string,
+) {
+ manifest.routes.unshift(getImageEndpointData(settings, mode, cwd));
+}
+
+function getImageEndpointData(
+ settings: AstroSettings,
+ mode: 'dev' | 'build',
+ cwd?: string,
+): RouteData {
+ const endpointEntrypoint =
+ settings.config.image.endpoint.entrypoint === undefined // If not set, use default endpoint
+ ? mode === 'dev'
+ ? 'astro/assets/endpoint/node'
+ : 'astro/assets/endpoint/generic'
+ : settings.config.image.endpoint.entrypoint;
+
+ const segments = [
+ [
+ {
+ content: removeTrailingForwardSlash(
+ removeLeadingForwardSlash(settings.config.image.endpoint.route),
+ ),
+ dynamic: false,
+ spread: false,
+ },
+ ],
+ ];
+
+ return {
+ type: 'endpoint',
+ isIndex: false,
+ route: settings.config.image.endpoint.route,
+ pattern: getPattern(segments, settings.config.base, settings.config.trailingSlash),
+ segments,
+ params: [],
+ component: resolveInjectedRoute(endpointEntrypoint, settings.config.root, cwd).component,
+ generate: () => '',
+ pathname: settings.config.image.endpoint.route,
+ prerender: false,
+ fallbackRoutes: [],
+ origin: 'internal',
+ };
+}
diff --git a/packages/astro/src/assets/endpoint/generic.ts b/packages/astro/src/assets/endpoint/generic.ts
new file mode 100644
index 000000000..d71d06987
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/generic.ts
@@ -0,0 +1,79 @@
+// @ts-expect-error
+import { imageConfig } from 'astro:assets';
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
+import * as mime from 'mrmime';
+import type { APIRoute } from '../../types/public/common.js';
+import { getConfiguredImageService } from '../internal.js';
+import { etag } from '../utils/etag.js';
+
+async function loadRemoteImage(src: URL, headers: Headers) {
+ try {
+ const res = await fetch(src, {
+ // Forward all headers from the original request
+ headers,
+ });
+
+ if (!res.ok) {
+ return undefined;
+ }
+
+ return await res.arrayBuffer();
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Endpoint used in dev and SSR to serve optimized images by the base image services
+ */
+export const GET: APIRoute = async ({ request }) => {
+ try {
+ const imageService = await getConfiguredImageService();
+
+ if (!('transform' in imageService)) {
+ throw new Error('Configured image service is not a local service');
+ }
+
+ const url = new URL(request.url);
+ const transform = await imageService.parseURL(url, imageConfig);
+
+ if (!transform?.src) {
+ throw new Error('Incorrect transform returned by `parseURL`');
+ }
+
+ let inputBuffer: ArrayBuffer | undefined = undefined;
+
+ const isRemoteImage = isRemotePath(transform.src);
+ const sourceUrl = isRemoteImage ? new URL(transform.src) : new URL(transform.src, url.origin);
+
+ if (isRemoteImage && isRemoteAllowed(transform.src, imageConfig) === false) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ inputBuffer = await loadRemoteImage(sourceUrl, isRemoteImage ? new Headers() : request.headers);
+
+ if (!inputBuffer) {
+ return new Response('Not Found', { status: 404 });
+ }
+
+ const { data, format } = await imageService.transform(
+ new Uint8Array(inputBuffer),
+ transform,
+ imageConfig,
+ );
+
+ return new Response(data, {
+ status: 200,
+ headers: {
+ 'Content-Type': mime.lookup(format) ?? `image/${format}`,
+ 'Cache-Control': 'public, max-age=31536000',
+ ETag: etag(data.toString()),
+ Date: new Date().toUTCString(),
+ },
+ });
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return new Response(`Server Error: ${err}`, { status: 500 });
+ }
+};
diff --git a/packages/astro/src/assets/endpoint/node.ts b/packages/astro/src/assets/endpoint/node.ts
new file mode 100644
index 000000000..991d7171f
--- /dev/null
+++ b/packages/astro/src/assets/endpoint/node.ts
@@ -0,0 +1,134 @@
+import { readFile } from 'node:fs/promises';
+
+import os from 'node:os';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+// @ts-expect-error
+import { assetsDir, imageConfig, outDir } from 'astro:assets';
+import { isRemotePath, removeQueryString } from '@astrojs/internal-helpers/path';
+import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
+import * as mime from 'mrmime';
+import type { APIRoute } from '../../types/public/common.js';
+import { getConfiguredImageService } from '../internal.js';
+import { etag } from '../utils/etag.js';
+
+function replaceFileSystemReferences(src: string) {
+ return os.platform().includes('win32') ? src.replace(/^\/@fs\//, '') : src.replace(/^\/@fs/, '');
+}
+
+async function loadLocalImage(src: string, url: URL) {
+ const assetsDirPath = fileURLToPath(assetsDir);
+
+ let fileUrl;
+ if (import.meta.env.DEV) {
+ fileUrl = pathToFileURL(removeQueryString(replaceFileSystemReferences(src)));
+ } else {
+ try {
+ // If the _image segment isn't at the start of the path, we have a base
+ const idx = url.pathname.indexOf('/_image');
+ if (idx > 0) {
+ // Remove the base path
+ src = src.slice(idx);
+ }
+ fileUrl = new URL('.' + src, outDir);
+ const filePath = fileURLToPath(fileUrl);
+
+ if (!isAbsolute(filePath) || !filePath.startsWith(assetsDirPath)) {
+ return undefined;
+ }
+ } catch {
+ return undefined;
+ }
+ }
+
+ let buffer: Buffer | undefined = undefined;
+
+ try {
+ buffer = await readFile(fileUrl);
+ } catch {
+ // Fallback to try to load the file using `fetch`
+ try {
+ const sourceUrl = new URL(src, url.origin);
+ buffer = await loadRemoteImage(sourceUrl);
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return undefined;
+ }
+ }
+
+ return buffer;
+}
+
+async function loadRemoteImage(src: URL) {
+ try {
+ const res = await fetch(src);
+
+ if (!res.ok) {
+ return undefined;
+ }
+
+ return Buffer.from(await res.arrayBuffer());
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Endpoint used in dev and SSR to serve optimized images by the base image services
+ */
+export const GET: APIRoute = async ({ request }) => {
+ try {
+ const imageService = await getConfiguredImageService();
+
+ if (!('transform' in imageService)) {
+ throw new Error('Configured image service is not a local service');
+ }
+
+ const url = new URL(request.url);
+ const transform = await imageService.parseURL(url, imageConfig);
+
+ if (!transform?.src) {
+ const err = new Error(
+ 'Incorrect transform returned by `parseURL`. Expected a transform with a `src` property.',
+ );
+ console.error('Could not parse image transform from URL:', err);
+ return new Response('Internal Server Error', { status: 500 });
+ }
+
+ let inputBuffer: Buffer | undefined = undefined;
+
+ if (isRemotePath(transform.src)) {
+ if (isRemoteAllowed(transform.src, imageConfig) === false) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ inputBuffer = await loadRemoteImage(new URL(transform.src));
+ } else {
+ inputBuffer = await loadLocalImage(transform.src, url);
+ }
+
+ if (!inputBuffer) {
+ return new Response('Internal Server Error', { status: 500 });
+ }
+
+ const { data, format } = await imageService.transform(inputBuffer, transform, imageConfig);
+
+ return new Response(data, {
+ status: 200,
+ headers: {
+ 'Content-Type': mime.lookup(format) ?? `image/${format}`,
+ 'Cache-Control': 'public, max-age=31536000',
+ ETag: etag(data.toString()),
+ Date: new Date().toUTCString(),
+ },
+ });
+ } catch (err: unknown) {
+ console.error('Could not process image request:', err);
+ return new Response(
+ import.meta.env.DEV ? `Could not process image request: ${err}` : `Internal Server Error`,
+ {
+ status: 500,
+ },
+ );
+ }
+};
diff --git a/packages/astro/src/assets/fonts/README.md b/packages/astro/src/assets/fonts/README.md
new file mode 100644
index 000000000..dd630a729
--- /dev/null
+++ b/packages/astro/src/assets/fonts/README.md
@@ -0,0 +1,20 @@
+# fonts
+
+Here is an overview of the architecture of the fonts in Astro:
+
+- [`orchestrate()`](./orchestrate.ts) combines sub steps and takes care of getting useful data from the config
+ - It resolves font families (eg. import remote font providers)
+ - It prepares [`unifont`](https://github.com/unjs/unifont) providers
+ - It initializes `unifont`
+ - For each family, it resolves fonts data and normalizes them
+ - For each family, optimized fallbacks (and related CSS) are generated if applicable
+ - It returns the data
+- [`/logic`](./logic/) contains the sub steps of `orchestrate()` so they can be easily tested
+- The logic uses [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control) to make it easily testable and swappable
+ - [`definitions.ts`](./definitions.ts) defines dependencies
+ - Those dependencies are implemented in [`/implementations`](./implementations/)
+- [`fontsPlugin()`](./vite-plugin-fonts.ts) calls `orchestrate()` and using its result, setups anything required so that fonts can
+ - Be exposed to users (virual module)
+ - Be used in dev (middleware)
+ - Be used in build (copy)
+- [`<Font />`](../../../components/Font.astro) is managed in [`assets()`](../vite-plugin-assets.ts) so it can be imported from `astro:assets` and consumes the virtual module
diff --git a/packages/astro/src/assets/fonts/config.ts b/packages/astro/src/assets/fonts/config.ts
new file mode 100644
index 000000000..73b28105b
--- /dev/null
+++ b/packages/astro/src/assets/fonts/config.ts
@@ -0,0 +1,178 @@
+import { z } from 'zod';
+import { LOCAL_PROVIDER_NAME } from './constants.js';
+
+const weightSchema = z.union([z.string(), z.number()]);
+export const styleSchema = z.enum(['normal', 'italic', 'oblique']);
+const unicodeRangeSchema = z.array(z.string()).nonempty();
+
+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.optional(),
+ /**
+ * A [font style](https://developer.mozilla.org/en-US/docs/Web/CSS/font-style).
+ */
+ style: styleSchema.optional(),
+ /**
+ * @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 [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), Astro will attempt to generate [optimized fallbacks](https://developer.chrome.com/blog/font-fallbacks) using font metrics will be generated. To disable this optimization, set `optimizedFallbacks` to false.
+ */
+ fallbacks: z.array(z.string()).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(),
+});
+
+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(),
+ /**
+ * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
+ */
+ unicodeRange: unicodeRangeSchema.optional(),
+ // 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(),
+ /**
+ * @default `["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]`
+ *
+ * An array of [font subsets](https://knaap.dev/posts/font-subsetting/):
+ */
+ subsets: z.array(z.string()).nonempty().optional(),
+ /**
+ * A [unicode range](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range).
+ */
+ unicodeRange: unicodeRangeSchema.optional(),
+ }),
+ )
+ .strict();
diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts
new file mode 100644
index 000000000..33993ef9c
--- /dev/null
+++ b/packages/astro/src/assets/fonts/constants.ts
@@ -0,0 +1,46 @@
+import type { Defaults, FontType } from './types.js';
+
+export const LOCAL_PROVIDER_NAME = 'local';
+
+export const DEFAULTS: Defaults = {
+ weights: ['400'],
+ styles: ['normal', 'italic'],
+ subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'],
+ // Technically serif is the browser default but most websites these days use sans-serif
+ fallbacks: ['sans-serif'],
+ optimizedFallbacks: true,
+};
+
+export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal';
+export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
+
+export const ASSETS_DIR = 'fonts';
+export const CACHE_DIR = './fonts/';
+
+export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'] as const;
+
+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',
+ 'sans-serif',
+ 'monospace',
+ 'cursive',
+ 'fantasy',
+ 'system-ui',
+ 'ui-serif',
+ 'ui-sans-serif',
+ 'ui-monospace',
+ 'ui-rounded',
+ 'emoji',
+ 'math',
+ 'fangsong',
+] as const;
+
+export const FONTS_TYPES_FILE = 'fonts.d.ts';
diff --git a/packages/astro/src/assets/fonts/definitions.ts b/packages/astro/src/assets/fonts/definitions.ts
new file mode 100644
index 000000000..4c3b840c5
--- /dev/null
+++ b/packages/astro/src/assets/fonts/definitions.ts
@@ -0,0 +1,116 @@
+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,
+ FontFileData,
+ FontType,
+ PreloadData,
+ ResolvedFontProvider,
+ Style,
+} from './types.js';
+import type { FontFaceMetrics, GenericFallbackName } from './types.js';
+
+export interface Hasher {
+ hashString: (input: string) => string;
+ hashObject: (input: Record<string, any>) => string;
+}
+
+export interface RemoteFontProviderModResolver {
+ resolve: (id: string) => Promise<any>;
+}
+
+export interface RemoteFontProviderResolver {
+ resolve: (provider: AstroFontProvider) => Promise<ResolvedFontProvider>;
+}
+
+export interface LocalProviderUrlResolver {
+ resolve: (input: string) => string;
+}
+
+type SingleErrorInput<TType extends string, TData extends Record<string, any>> = {
+ type: TType;
+ data: TData;
+ cause: unknown;
+};
+
+export type ErrorHandlerInput =
+ | SingleErrorInput<
+ 'cannot-load-font-provider',
+ {
+ entrypoint: string;
+ }
+ >
+ | SingleErrorInput<'unknown-fs-error', {}>
+ | SingleErrorInput<'cannot-fetch-font-file', { url: string }>
+ | SingleErrorInput<'cannot-extract-font-type', { url: string }>
+ | SingleErrorInput<'cannot-extract-data', { family: string; url: string }>;
+
+export interface ErrorHandler {
+ handle: (input: ErrorHandlerInput) => Error;
+}
+
+export interface UrlProxy {
+ proxy: (
+ input: Pick<FontFileData, 'url' | 'init'> & {
+ type: FontType;
+ collectPreload: boolean;
+ data: Partial<unifont.FontFaceData>;
+ },
+ ) => string;
+}
+
+export interface UrlResolver {
+ resolve: (hash: string) => string;
+}
+
+export interface UrlProxyContentResolver {
+ resolve: (url: string) => string;
+}
+
+export interface DataCollector {
+ collect: (
+ input: FontFileData & {
+ data: Partial<unifont.FontFaceData>;
+ preload: PreloadData | null;
+ },
+ ) => void;
+}
+
+export type CssProperties = Record<string, string | undefined>;
+
+export interface CssRenderer {
+ generateFontFace: (family: string, properties: CssProperties) => string;
+ generateCssVariable: (key: string, values: Array<string>) => string;
+}
+
+export interface FontMetricsResolver {
+ getMetrics: (name: string, font: CollectedFontForMetrics) => Promise<FontFaceMetrics>;
+ generateFontFace: (input: {
+ metrics: FontFaceMetrics;
+ fallbackMetrics: FontFaceMetrics;
+ name: string;
+ font: string;
+ properties: CssProperties;
+ }) => string;
+}
+
+export interface SystemFallbacksProvider {
+ getLocalFonts: (fallback: GenericFallbackName) => Array<string> | null;
+ getMetricsForLocalFont: (family: string) => FontFaceMetrics;
+}
+
+export interface FontFetcher {
+ fetch: (input: FontFileData) => Promise<Buffer>;
+}
+
+export interface FontTypeExtractor {
+ extract: (url: string) => FontType;
+}
+
+export interface FontFileReader {
+ extract: (input: { family: string; url: string }) => {
+ weight: string;
+ style: Style;
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/css-renderer.ts b/packages/astro/src/assets/fonts/implementations/css-renderer.ts
new file mode 100644
index 000000000..3ae6d0393
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/css-renderer.ts
@@ -0,0 +1,50 @@
+import type { CssProperties, CssRenderer } from '../definitions.js';
+
+export function renderFontFace(properties: CssProperties, minify: boolean): string {
+ // Line feed
+ const lf = minify ? '' : `\n`;
+ // Space
+ const sp = minify ? '' : ' ';
+
+ return `@font-face${sp}{${lf}${Object.entries(properties)
+ .filter(([, value]) => Boolean(value))
+ .map(([key, value]) => `${sp}${sp}${key}:${sp}${value};`)
+ .join(lf)}${lf}}${lf}`;
+}
+
+export function renderCssVariable(key: string, values: Array<string>, minify: boolean): string {
+ // Line feed
+ const lf = minify ? '' : `\n`;
+ // Space
+ const sp = minify ? '' : ' ';
+
+ return `:root${sp}{${lf}${sp}${sp}${key}:${sp}${values.map((v) => handleValueWithSpaces(v)).join(`,${sp}`)};${lf}}${lf}`;
+}
+
+export function withFamily(family: string, properties: CssProperties): CssProperties {
+ return {
+ 'font-family': handleValueWithSpaces(family),
+ ...properties,
+ };
+}
+
+const SPACE_RE = /\s/;
+
+/** If the value contains spaces (which would be incorrectly interpreted), we wrap it in quotes. */
+export function handleValueWithSpaces(value: string): string {
+ if (SPACE_RE.test(value)) {
+ return JSON.stringify(value);
+ }
+ return value;
+}
+
+export function createMinifiableCssRenderer({ minify }: { minify: boolean }): CssRenderer {
+ return {
+ generateFontFace(family, properties) {
+ return renderFontFace(withFamily(family, properties), minify);
+ },
+ generateCssVariable(key, values) {
+ return renderCssVariable(key, values, minify);
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/data-collector.ts b/packages/astro/src/assets/fonts/implementations/data-collector.ts
new file mode 100644
index 000000000..43f29b14f
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/data-collector.ts
@@ -0,0 +1,21 @@
+import type { DataCollector } from '../definitions.js';
+import type { CreateUrlProxyParams } from '../types.js';
+
+export function createDataCollector({
+ hasUrl,
+ saveUrl,
+ savePreload,
+ saveFontData,
+}: Omit<CreateUrlProxyParams, 'local'>): DataCollector {
+ return {
+ collect({ hash, url, init, preload, data }) {
+ if (!hasUrl(hash)) {
+ saveUrl({ hash, url, init });
+ if (preload) {
+ savePreload(preload);
+ }
+ }
+ saveFontData({ hash, url, data, init });
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/error-handler.ts b/packages/astro/src/assets/fonts/implementations/error-handler.ts
new file mode 100644
index 000000000..a53ed4bc7
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/error-handler.ts
@@ -0,0 +1,42 @@
+import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
+import type { ErrorHandler, ErrorHandlerInput } from '../definitions.js';
+
+function getProps(input: ErrorHandlerInput): ConstructorParameters<typeof AstroError>[0] {
+ if (input.type === 'cannot-load-font-provider') {
+ return {
+ ...AstroErrorData.CannotLoadFontProvider,
+ message: AstroErrorData.CannotLoadFontProvider.message(input.data.entrypoint),
+ };
+ } else if (input.type === 'unknown-fs-error') {
+ return AstroErrorData.UnknownFilesystemError;
+ } else if (input.type === 'cannot-fetch-font-file') {
+ return {
+ ...AstroErrorData.CannotFetchFontFile,
+ message: AstroErrorData.CannotFetchFontFile.message(input.data.url),
+ };
+ } else if (input.type === 'cannot-extract-font-type') {
+ return {
+ ...AstroErrorData.CannotExtractFontType,
+ message: AstroErrorData.CannotExtractFontType.message(input.data.url),
+ };
+ } else if (input.type === 'cannot-extract-data') {
+ return {
+ ...AstroErrorData.CannotDetermineWeightAndStyleFromFontFile,
+ message: AstroErrorData.CannotDetermineWeightAndStyleFromFontFile.message(
+ input.data.family,
+ input.data.url,
+ ),
+ };
+ }
+ input satisfies never;
+ // Should never happen but TS isn't happy
+ return AstroErrorData.UnknownError;
+}
+
+export function createAstroErrorHandler(): ErrorHandler {
+ return {
+ handle(input) {
+ return new AstroError(getProps(input), { cause: input.cause });
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/font-fetcher.ts b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts
new file mode 100644
index 000000000..9bc2ae8ff
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/font-fetcher.ts
@@ -0,0 +1,39 @@
+import { isAbsolute } from 'node:path';
+import type { Storage } from 'unstorage';
+import type { ErrorHandler, FontFetcher } from '../definitions.js';
+import { cache } from '../utils.js';
+
+export function createCachedFontFetcher({
+ storage,
+ errorHandler,
+ fetch,
+ readFile,
+}: {
+ storage: Storage;
+ errorHandler: ErrorHandler;
+ fetch: (url: string, init?: RequestInit) => Promise<Response>;
+ readFile: (url: string) => Promise<Buffer>;
+}): FontFetcher {
+ return {
+ async fetch({ hash, url, init }) {
+ return await cache(storage, hash, async () => {
+ try {
+ if (isAbsolute(url)) {
+ return await readFile(url);
+ }
+ const response = await fetch(url, init ?? undefined);
+ if (!response.ok) {
+ throw new Error(`Response was not successful, received status code ${response.status}`);
+ }
+ return Buffer.from(await response.arrayBuffer());
+ } catch (cause) {
+ throw errorHandler.handle({
+ type: 'cannot-fetch-font-file',
+ data: { url },
+ cause,
+ });
+ }
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/font-file-reader.ts b/packages/astro/src/assets/fonts/implementations/font-file-reader.ts
new file mode 100644
index 000000000..f935288e7
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/font-file-reader.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from 'node:fs';
+import { fontace } from 'fontace';
+import type { ErrorHandler, FontFileReader } from '../definitions.js';
+import type { Style } from '../types.js';
+
+export function createFontaceFontFileReader({
+ errorHandler,
+}: { errorHandler: ErrorHandler }): FontFileReader {
+ return {
+ extract({ family, url }) {
+ try {
+ const data = fontace(readFileSync(url));
+ return {
+ weight: data.weight,
+ style: data.style as Style,
+ };
+ } catch (cause) {
+ throw errorHandler.handle({
+ type: 'cannot-extract-data',
+ data: { family, url },
+ cause,
+ });
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts
new file mode 100644
index 000000000..2fe3baa44
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/font-metrics-resolver.ts
@@ -0,0 +1,73 @@
+import { type Font, fromBuffer } from '@capsizecss/unpack';
+import type { CssRenderer, FontFetcher, FontMetricsResolver } from '../definitions.js';
+import type { FontFaceMetrics } from '../types.js';
+import { renderFontSrc } from '../utils.js';
+
+// Source: https://github.com/unjs/fontaine/blob/main/src/metrics.ts
+function filterRequiredMetrics({
+ ascent,
+ descent,
+ lineGap,
+ unitsPerEm,
+ xWidthAvg,
+}: Pick<Font, 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'>) {
+ return {
+ ascent,
+ descent,
+ lineGap,
+ unitsPerEm,
+ xWidthAvg,
+ };
+}
+
+// Source: https://github.com/unjs/fontaine/blob/f00f84032c5d5da72c8798eae4cd68d3ddfbf340/src/css.ts#L7
+function toPercentage(value: number, fractionDigits = 4) {
+ const percentage = value * 100;
+ return `${+percentage.toFixed(fractionDigits)}%`;
+}
+
+export function createCapsizeFontMetricsResolver({
+ fontFetcher,
+ cssRenderer,
+}: {
+ fontFetcher: FontFetcher;
+ cssRenderer: CssRenderer;
+}): FontMetricsResolver {
+ const cache: Record<string, FontFaceMetrics | null> = {};
+
+ return {
+ 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
+ generateFontFace({
+ metrics,
+ fallbackMetrics,
+ name: fallbackName,
+ font: fallbackFontName,
+ properties,
+ }) {
+ // Calculate size adjust
+ const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm;
+ const fallbackFontXAvgRatio = fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm;
+ const sizeAdjust = preferredFontXAvgRatio / fallbackFontXAvgRatio;
+
+ const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust;
+
+ // Calculate metric overrides for preferred font
+ const ascentOverride = metrics.ascent / adjustedEmSquare;
+ const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare;
+ const lineGapOverride = metrics.lineGap / adjustedEmSquare;
+
+ return cssRenderer.generateFontFace(fallbackName, {
+ ...properties,
+ src: renderFontSrc([{ name: fallbackFontName }]),
+ 'size-adjust': toPercentage(sizeAdjust),
+ 'ascent-override': toPercentage(ascentOverride),
+ 'descent-override': toPercentage(descentOverride),
+ 'line-gap-override': toPercentage(lineGapOverride),
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts b/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts
new file mode 100644
index 000000000..b61626bb7
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/font-type-extractor.ts
@@ -0,0 +1,21 @@
+import { extname } from 'node:path';
+import type { ErrorHandler, FontTypeExtractor } from '../definitions.js';
+import { isFontType } from '../utils.js';
+
+export function createFontTypeExtractor({
+ errorHandler,
+}: { errorHandler: ErrorHandler }): FontTypeExtractor {
+ return {
+ extract(url) {
+ const extension = extname(url).slice(1);
+ if (!isFontType(extension)) {
+ throw errorHandler.handle({
+ type: 'cannot-extract-font-type',
+ data: { url },
+ cause: `Unexpected extension, got "${extension}"`,
+ });
+ }
+ return extension;
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/hasher.ts b/packages/astro/src/assets/fonts/implementations/hasher.ts
new file mode 100644
index 000000000..2772284c4
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/hasher.ts
@@ -0,0 +1,13 @@
+import xxhash from 'xxhash-wasm';
+import type { Hasher } from '../definitions.js';
+import { sortObjectByKey } from '../utils.js';
+
+export async function createXxHasher(): Promise<Hasher> {
+ const { h64ToString: hashString } = await xxhash();
+ return {
+ hashString,
+ hashObject(input) {
+ return hashString(JSON.stringify(sortObjectByKey(input)));
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts
new file mode 100644
index 000000000..13f8c65ac
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/local-provider-url-resolver.ts
@@ -0,0 +1,22 @@
+import { fileURLToPath } from 'node:url';
+import type { LocalProviderUrlResolver } from '../definitions.js';
+import { resolveEntrypoint } from '../utils.js';
+
+export function createRequireLocalProviderUrlResolver({
+ root,
+ intercept,
+}: {
+ root: URL;
+ // TODO: remove when stabilizing
+ intercept?: (path: string) => void;
+}): LocalProviderUrlResolver {
+ return {
+ resolve(input) {
+ // fileURLToPath is important so that the file can be read
+ // by createLocalUrlProxyContentResolver
+ const path = fileURLToPath(resolveEntrypoint(root, input));
+ intercept?.(path);
+ return path;
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts b/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts
new file mode 100644
index 000000000..7dc3df1e7
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-mod-resolver.ts
@@ -0,0 +1,20 @@
+import type { ViteDevServer } from 'vite';
+import type { RemoteFontProviderModResolver } from '../definitions.js';
+
+export function createBuildRemoteFontProviderModResolver(): RemoteFontProviderModResolver {
+ return {
+ resolve(id) {
+ return import(id);
+ },
+ };
+}
+
+export function createDevServerRemoteFontProviderModResolver({
+ server,
+}: { server: ViteDevServer }): RemoteFontProviderModResolver {
+ return {
+ resolve(id) {
+ return server.ssrLoadModule(id);
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts
new file mode 100644
index 000000000..f70a99fbc
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/remote-font-provider-resolver.ts
@@ -0,0 +1,62 @@
+import type {
+ ErrorHandler,
+ RemoteFontProviderModResolver,
+ RemoteFontProviderResolver,
+} from '../definitions.js';
+import type { ResolvedFontProvider } from '../types.js';
+import { resolveEntrypoint } from '../utils.js';
+
+function validateMod({
+ mod,
+ entrypoint,
+ errorHandler,
+}: { mod: any; entrypoint: string; errorHandler: ErrorHandler }): Pick<
+ ResolvedFontProvider,
+ 'provider'
+> {
+ // We do not throw astro errors directly to avoid duplication. Instead, we throw an error to be used as cause
+ try {
+ if (typeof mod !== 'object' || mod === null) {
+ throw new Error(`Expected an object for the module, but received ${typeof mod}.`);
+ }
+
+ if (typeof mod.provider !== 'function') {
+ throw new Error(`Invalid provider export in module, expected a function.`);
+ }
+
+ return {
+ provider: mod.provider,
+ };
+ } catch (cause) {
+ throw errorHandler.handle({
+ type: 'cannot-load-font-provider',
+ data: {
+ entrypoint,
+ },
+ cause,
+ });
+ }
+}
+
+export function createRemoteFontProviderResolver({
+ root,
+ modResolver,
+ errorHandler,
+}: {
+ root: URL;
+ modResolver: RemoteFontProviderModResolver;
+ errorHandler: ErrorHandler;
+}): RemoteFontProviderResolver {
+ return {
+ async resolve({ entrypoint, config }) {
+ const id = resolveEntrypoint(root, entrypoint.toString()).href;
+ const mod = await modResolver.resolve(id);
+ const { provider } = validateMod({
+ mod,
+ entrypoint: id,
+ errorHandler,
+ });
+ return { config, provider };
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/storage.ts b/packages/astro/src/assets/fonts/implementations/storage.ts
new file mode 100644
index 000000000..ad0f6ae8c
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/storage.ts
@@ -0,0 +1,12 @@
+import { fileURLToPath } from 'node:url';
+import { type Storage, createStorage } from 'unstorage';
+import fsLiteDriver from 'unstorage/drivers/fs-lite';
+
+export function createFsStorage({ base }: { base: URL }): Storage {
+ return createStorage({
+ // Types are weirly exported
+ driver: (fsLiteDriver as unknown as typeof fsLiteDriver.default)({
+ base: fileURLToPath(base),
+ }),
+ });
+}
diff --git a/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts
new file mode 100644
index 000000000..3e2340c78
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/system-fallbacks-provider.ts
@@ -0,0 +1,79 @@
+import type { SystemFallbacksProvider } from '../definitions.js';
+import type { FontFaceMetrics, GenericFallbackName } from '../types.js';
+
+// Extracted from https://raw.githubusercontent.com/seek-oss/capsize/refs/heads/master/packages/metrics/src/entireMetricsCollection.json
+const SYSTEM_METRICS = {
+ 'Times New Roman': {
+ ascent: 1825,
+ descent: -443,
+ lineGap: 87,
+ unitsPerEm: 2048,
+ xWidthAvg: 832,
+ },
+ Arial: {
+ ascent: 1854,
+ descent: -434,
+ lineGap: 67,
+ unitsPerEm: 2048,
+ xWidthAvg: 913,
+ },
+ 'Courier New': {
+ ascent: 1705,
+ descent: -615,
+ lineGap: 0,
+ unitsPerEm: 2048,
+ xWidthAvg: 1229,
+ },
+ BlinkMacSystemFont: {
+ ascent: 1980,
+ descent: -432,
+ lineGap: 0,
+ unitsPerEm: 2048,
+ xWidthAvg: 853,
+ },
+ 'Segoe UI': {
+ ascent: 2210,
+ descent: -514,
+ lineGap: 0,
+ unitsPerEm: 2048,
+ xWidthAvg: 908,
+ },
+ Roboto: {
+ ascent: 1900,
+ descent: -500,
+ lineGap: 0,
+ unitsPerEm: 2048,
+ xWidthAvg: 911,
+ },
+ 'Helvetica Neue': {
+ ascent: 952,
+ descent: -213,
+ lineGap: 28,
+ unitsPerEm: 1000,
+ xWidthAvg: 450,
+ },
+} satisfies Record<string, FontFaceMetrics>;
+
+type FallbackName = keyof typeof SYSTEM_METRICS;
+
+// Source: https://github.com/nuxt/fonts/blob/3a3eb6dfecc472242b3011b25f3fcbae237d0acc/src/module.ts#L55-L75
+export const DEFAULT_FALLBACKS = {
+ serif: ['Times New Roman'],
+ 'sans-serif': ['Arial'],
+ monospace: ['Courier New'],
+ 'system-ui': ['BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial'],
+ 'ui-serif': ['Times New Roman'],
+ 'ui-sans-serif': ['Arial'],
+ 'ui-monospace': ['Courier New'],
+} satisfies Partial<Record<GenericFallbackName, Array<FallbackName>>>;
+
+export function createSystemFallbacksProvider(): SystemFallbacksProvider {
+ return {
+ getLocalFonts(fallback) {
+ return DEFAULT_FALLBACKS[fallback as keyof typeof DEFAULT_FALLBACKS] ?? null;
+ },
+ getMetricsForLocalFont(family) {
+ return SYSTEM_METRICS[family as FallbackName];
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts
new file mode 100644
index 000000000..2a0aa1d75
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/url-proxy-content-resolver.ts
@@ -0,0 +1,30 @@
+import { readFileSync } from 'node:fs';
+import type { ErrorHandler, UrlProxyContentResolver } from '../definitions.js';
+
+export function createLocalUrlProxyContentResolver({
+ errorHandler,
+}: { errorHandler: ErrorHandler }): UrlProxyContentResolver {
+ return {
+ resolve(url) {
+ try {
+ // We use the url and the file content for the hash generation because:
+ // - The URL is not hashed unlike remote providers
+ // - A font file can renamed and swapped so we would incorrectly cache it
+ return url + readFileSync(url, 'utf-8');
+ } catch (cause) {
+ throw errorHandler.handle({
+ type: 'unknown-fs-error',
+ data: {},
+ cause,
+ });
+ }
+ },
+ };
+}
+
+export function createRemoteUrlProxyContentResolver(): UrlProxyContentResolver {
+ return {
+ // Passthrough, the remote provider URL is enough
+ resolve: (url) => url,
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/url-proxy.ts b/packages/astro/src/assets/fonts/implementations/url-proxy.ts
new file mode 100644
index 000000000..d41152c6b
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/url-proxy.ts
@@ -0,0 +1,36 @@
+import type {
+ DataCollector,
+ Hasher,
+ UrlProxy,
+ UrlProxyContentResolver,
+ UrlResolver,
+} from '../definitions.js';
+
+export function createUrlProxy({
+ contentResolver,
+ hasher,
+ dataCollector,
+ urlResolver,
+}: {
+ contentResolver: UrlProxyContentResolver;
+ hasher: Hasher;
+ dataCollector: DataCollector;
+ urlResolver: UrlResolver;
+}): UrlProxy {
+ return {
+ proxy({ url: originalUrl, type, data, collectPreload, init }) {
+ const hash = `${hasher.hashString(contentResolver.resolve(originalUrl))}.${type}`;
+ const url = urlResolver.resolve(hash);
+
+ dataCollector.collect({
+ url: originalUrl,
+ hash,
+ preload: collectPreload ? { url, type } : null,
+ data,
+ init,
+ });
+
+ return url;
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/implementations/url-resolver.ts b/packages/astro/src/assets/fonts/implementations/url-resolver.ts
new file mode 100644
index 000000000..d5290f348
--- /dev/null
+++ b/packages/astro/src/assets/fonts/implementations/url-resolver.ts
@@ -0,0 +1,27 @@
+import { fileExtension, joinPaths, prependForwardSlash } from '../../../core/path.js';
+import type { AssetsPrefix } from '../../../types/public/index.js';
+import { getAssetsPrefix } from '../../utils/getAssetsPrefix.js';
+import type { UrlResolver } from '../definitions.js';
+
+export function createDevUrlResolver({ base }: { base: string }): UrlResolver {
+ return {
+ resolve(hash) {
+ return prependForwardSlash(joinPaths(base, hash));
+ },
+ };
+}
+
+export function createBuildUrlResolver({
+ base,
+ assetsPrefix,
+}: { base: string; assetsPrefix: AssetsPrefix }): UrlResolver {
+ return {
+ resolve(hash) {
+ const prefix = assetsPrefix ? getAssetsPrefix(fileExtension(hash), assetsPrefix) : undefined;
+ if (prefix) {
+ return joinPaths(prefix, base, hash);
+ }
+ return prependForwardSlash(joinPaths(base, hash));
+ },
+ };
+}
diff --git a/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts
new file mode 100644
index 000000000..c6bdbac6d
--- /dev/null
+++ b/packages/astro/src/assets/fonts/logic/extract-unifont-providers.ts
@@ -0,0 +1,46 @@
+import type * as unifont from 'unifont';
+import { LOCAL_PROVIDER_NAME } from '../constants.js';
+import type { Hasher } from '../definitions.js';
+import type { ResolvedFontFamily } from '../types.js';
+
+export function extractUnifontProviders({
+ families,
+ hasher,
+}: {
+ families: Array<ResolvedFontFamily>;
+ hasher: Hasher;
+}): {
+ families: Array<ResolvedFontFamily>;
+ providers: Array<unifont.Provider>;
+} {
+ const hashes = new Set<string>();
+ const providers: Array<unifont.Provider> = [];
+
+ for (const { provider } of families) {
+ // The local provider logic happens outside of unifont
+ if (provider === LOCAL_PROVIDER_NAME) {
+ continue;
+ }
+
+ const unifontProvider = provider.provider(provider.config);
+ const hash = hasher.hashObject({
+ name: unifontProvider._name,
+ ...provider.config,
+ });
+ // Makes sure every font uses the right instance of a given provider
+ // if this provider is provided several times with different options
+ // We have to mutate the unifont provider name because unifont deduplicates
+ // based on the name.
+ unifontProvider._name += `-${hash}`;
+ // We set the provider name so we can tell unifont what provider to use when
+ // resolving font faces
+ provider.name = unifontProvider._name;
+
+ if (!hashes.has(hash)) {
+ hashes.add(hash);
+ providers.push(unifontProvider);
+ }
+ }
+
+ return { families, providers };
+}
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
new file mode 100644
index 000000000..efcdb61a4
--- /dev/null
+++ b/packages/astro/src/assets/fonts/logic/normalize-remote-font-faces.ts
@@ -0,0 +1,56 @@
+import type * as unifont from 'unifont';
+import { FONT_FORMATS } from '../constants.js';
+import type { FontTypeExtractor, UrlProxy } from '../definitions.js';
+
+export function normalizeRemoteFontFaces({
+ fonts,
+ urlProxy,
+ fontTypeExtractor,
+}: {
+ fonts: Array<unifont.FontFaceData>;
+ urlProxy: UrlProxy;
+ fontTypeExtractor: FontTypeExtractor;
+}): Array<unifont.FontFaceData> {
+ return (
+ fonts
+ // Avoid getting too much font files
+ .filter((font) => (typeof font.meta?.priority === 'number' ? font.meta.priority === 0 : true))
+ // Collect URLs
+ .map((font) => {
+ // The index keeps track of encountered URLs. We can't use the index on font.src.map
+ // below because it may contain sources without urls, which would prevent preloading completely
+ let index = 0;
+ return {
+ ...font,
+ src: font.src.map((source) => {
+ if ('name' in source) {
+ return source;
+ }
+ // We handle protocol relative URLs here, otherwise they're considered absolute by the font
+ // fetcher which will try to read them from the file system
+ const url = source.url.startsWith('//') ? `https:${source.url}` : source.url;
+ const proxied = {
+ ...source,
+ 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,
+ data: {
+ weight: font.weight,
+ style: font.style,
+ },
+ init: font.meta?.init ?? null,
+ }),
+ };
+ index++;
+ return proxied;
+ }),
+ };
+ })
+ );
+}
diff --git a/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts
new file mode 100644
index 000000000..973534b09
--- /dev/null
+++ b/packages/astro/src/assets/fonts/logic/optimize-fallbacks.ts
@@ -0,0 +1,78 @@
+import type * as unifont from 'unifont';
+import type { FontMetricsResolver, SystemFallbacksProvider } from '../definitions.js';
+import type { FontFileData, ResolvedFontFamily } from '../types.js';
+import { isGenericFontFamily, unifontFontFaceDataToProperties } from '../utils.js';
+
+export interface CollectedFontForMetrics extends FontFileData {
+ data: Partial<unifont.FontFaceData>;
+}
+
+export async function optimizeFallbacks({
+ family,
+ fallbacks: _fallbacks,
+ collectedFonts,
+ enabled,
+ systemFallbacksProvider,
+ fontMetricsResolver,
+}: {
+ family: Pick<ResolvedFontFamily, 'name' | 'nameWithHash'>;
+ fallbacks: Array<string>;
+ collectedFonts: Array<CollectedFontForMetrics>;
+ enabled: boolean;
+ systemFallbacksProvider: SystemFallbacksProvider;
+ fontMetricsResolver: FontMetricsResolver;
+}): Promise<null | {
+ css: string;
+ fallbacks: Array<string>;
+}> {
+ // We avoid mutating the original array
+ let fallbacks = [..._fallbacks];
+
+ if (fallbacks.length === 0 || !enabled || collectedFonts.length === 0) {
+ return null;
+ }
+
+ // The last element of the fallbacks is usually a generic family name (eg. serif)
+ const lastFallback = fallbacks[fallbacks.length - 1];
+ // If it's not a generic family name, we can't infer local fonts to be used as fallbacks
+ if (!isGenericFontFamily(lastFallback)) {
+ return null;
+ }
+
+ // If it's a generic family name, we get the associated local fonts (eg. Arial)
+ const localFonts = systemFallbacksProvider.getLocalFonts(lastFallback);
+ // Some generic families do not have associated local fonts so we abort early
+ if (!localFonts || localFonts.length === 0) {
+ return null;
+ }
+
+ // If the family is already a system font, no need to generate fallbacks
+ if (localFonts.includes(family.name)) {
+ return null;
+ }
+
+ const localFontsMappings = localFonts.map((font) => ({
+ font,
+ // We must't wrap in quote because that's handled by the CSS renderer
+ name: `${family.nameWithHash} fallback: ${font}`,
+ }));
+
+ // We prepend the fallbacks with the local fonts and we dedupe in case a local font is already provided
+ fallbacks = [...localFontsMappings.map((m) => m.name), ...fallbacks];
+ let css = '';
+
+ for (const { font, name } of localFontsMappings) {
+ 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, collected),
+ fallbackMetrics: systemFallbacksProvider.getMetricsForLocalFont(font),
+ font,
+ name,
+ properties: unifontFontFaceDataToProperties(collected.data),
+ });
+ }
+ }
+
+ return { css, fallbacks };
+}
diff --git a/packages/astro/src/assets/fonts/logic/resolve-families.ts b/packages/astro/src/assets/fonts/logic/resolve-families.ts
new file mode 100644
index 000000000..17fd48799
--- /dev/null
+++ b/packages/astro/src/assets/fonts/logic/resolve-families.ts
@@ -0,0 +1,99 @@
+import { LOCAL_PROVIDER_NAME } from '../constants.js';
+import type {
+ Hasher,
+ LocalProviderUrlResolver,
+ RemoteFontProviderResolver,
+} from '../definitions.js';
+import type {
+ FontFamily,
+ LocalFontFamily,
+ ResolvedFontFamily,
+ ResolvedLocalFontFamily,
+} from '../types.js';
+import { dedupe, withoutQuotes } from '../utils.js';
+
+function resolveVariants({
+ variants,
+ localProviderUrlResolver,
+}: {
+ variants: LocalFontFamily['variants'];
+ localProviderUrlResolver: LocalProviderUrlResolver;
+}): ResolvedLocalFontFamily['variants'] {
+ return variants.map((variant) => ({
+ ...variant,
+ weight: variant.weight?.toString(),
+ src: variant.src.map((value) => {
+ // A src can be a string or an object, we extract the value accordingly.
+ const isValue = typeof value === 'string' || value instanceof URL;
+ const url = (isValue ? value : value.url).toString();
+ const tech = isValue ? undefined : value.tech;
+ return {
+ url: localProviderUrlResolver.resolve(url),
+ tech,
+ };
+ }),
+ }));
+}
+
+/**
+ * Dedupes properties if applicable and resolves entrypoints.
+ */
+export async function resolveFamily({
+ family,
+ hasher,
+ remoteFontProviderResolver,
+ localProviderUrlResolver,
+}: {
+ family: FontFamily;
+ hasher: Hasher;
+ remoteFontProviderResolver: RemoteFontProviderResolver;
+ localProviderUrlResolver: LocalProviderUrlResolver;
+}): Promise<ResolvedFontFamily> {
+ // We remove quotes from the name so they can be properly resolved by providers.
+ const name = withoutQuotes(family.name);
+ // This will be used in CSS font faces. Quotes are added by the CSS renderer if
+ // this value contains a space.
+ const nameWithHash = `${name}-${hasher.hashObject(family)}`;
+
+ if (family.provider === LOCAL_PROVIDER_NAME) {
+ return {
+ ...family,
+ name,
+ nameWithHash,
+ variants: resolveVariants({ variants: family.variants, localProviderUrlResolver }),
+ fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined,
+ };
+ }
+
+ return {
+ ...family,
+ name,
+ nameWithHash,
+ weights: family.weights ? dedupe(family.weights.map((weight) => weight.toString())) : undefined,
+ styles: family.styles ? dedupe(family.styles) : undefined,
+ subsets: family.subsets ? dedupe(family.subsets) : undefined,
+ fallbacks: family.fallbacks ? dedupe(family.fallbacks) : undefined,
+ unicodeRange: family.unicodeRange ? dedupe(family.unicodeRange) : undefined,
+ // This will be Astro specific eventually
+ provider: await remoteFontProviderResolver.resolve(family.provider),
+ };
+}
+
+/**
+ * A function for convenience. The actual logic lives in resolveFamily
+ */
+export async function resolveFamilies({
+ families,
+ ...dependencies
+}: { families: Array<FontFamily> } & Omit<Parameters<typeof resolveFamily>[0], 'family'>): Promise<
+ Array<ResolvedFontFamily>
+> {
+ return await Promise.all(
+ families.map((family) =>
+ resolveFamily({
+ family,
+ ...dependencies,
+ }),
+ ),
+ );
+}
diff --git a/packages/astro/src/assets/fonts/orchestrate.ts b/packages/astro/src/assets/fonts/orchestrate.ts
new file mode 100644
index 000000000..5f799dc4e
--- /dev/null
+++ b/packages/astro/src/assets/fonts/orchestrate.ts
@@ -0,0 +1,227 @@
+import { bold } from 'kleur/colors';
+import * as unifont from 'unifont';
+import type { Storage } from 'unstorage';
+import type { Logger } from '../../core/logger/core.js';
+import { LOCAL_PROVIDER_NAME } from './constants.js';
+import type {
+ CssRenderer,
+ FontFileReader,
+ FontMetricsResolver,
+ FontTypeExtractor,
+ Hasher,
+ LocalProviderUrlResolver,
+ RemoteFontProviderResolver,
+ SystemFallbacksProvider,
+ UrlProxy,
+} from './definitions.js';
+import { extractUnifontProviders } from './logic/extract-unifont-providers.js';
+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 {
+ ConsumableMap,
+ CreateUrlProxyParams,
+ Defaults,
+ FontFamily,
+ FontFileDataMap,
+ PreloadData,
+} from './types.js';
+import { pickFontFaceProperty, unifontFontFaceDataToProperties } from './utils.js';
+
+/**
+ * Manages how fonts are resolved:
+ *
+ * - families are resolved
+ * - unifont providers are extracted from families
+ * - unifont is initialized
+ *
+ * For each family:
+ * - We create a URL proxy
+ * - We resolve the font and normalize the result
+ *
+ * For each resolved font:
+ * - We generate the CSS font face
+ * - We generate optimized fallbacks if applicable
+ * - We generate CSS variables
+ *
+ * Once that's done, the collected data is returned
+ */
+export async function orchestrate({
+ families,
+ hasher,
+ remoteFontProviderResolver,
+ localProviderUrlResolver,
+ storage,
+ cssRenderer,
+ systemFallbacksProvider,
+ fontMetricsResolver,
+ fontTypeExtractor,
+ fontFileReader,
+ logger,
+ createUrlProxy,
+ defaults,
+}: {
+ families: Array<FontFamily>;
+ hasher: Hasher;
+ remoteFontProviderResolver: RemoteFontProviderResolver;
+ localProviderUrlResolver: LocalProviderUrlResolver;
+ storage: Storage;
+ cssRenderer: CssRenderer;
+ systemFallbacksProvider: SystemFallbacksProvider;
+ fontMetricsResolver: FontMetricsResolver;
+ fontTypeExtractor: FontTypeExtractor;
+ fontFileReader: FontFileReader;
+ // TODO: follow this implementation: https://github.com/withastro/astro/pull/13756/commits/e30ac2b7082a3eed36225da6e88449890cbcbe6b
+ logger: Logger;
+ createUrlProxy: (params: CreateUrlProxyParams) => UrlProxy;
+ defaults: Defaults;
+}): Promise<{
+ fontFileDataMap: FontFileDataMap;
+ consumableMap: ConsumableMap;
+}> {
+ let resolvedFamilies = await resolveFamilies({
+ families,
+ hasher,
+ remoteFontProviderResolver,
+ localProviderUrlResolver,
+ });
+
+ const extractedUnifontProvidersResult = extractUnifontProviders({
+ families: resolvedFamilies,
+ hasher,
+ });
+ resolvedFamilies = extractedUnifontProvidersResult.families;
+ const unifontProviders = extractedUnifontProvidersResult.providers;
+
+ const { resolveFont } = await unifont.createUnifont(unifontProviders, {
+ storage,
+ });
+
+ /**
+ * Holds associations of hash and original font file URLs, so they can be
+ * downloaded whenever the hash is requested.
+ */
+ const fontFileDataMap: FontFileDataMap = new Map();
+ /**
+ * Holds associations of CSS variables and preloadData/css to be passed to the virtual module.
+ */
+ const consumableMap: ConsumableMap = new Map();
+
+ for (const family of resolvedFamilies) {
+ const preloadData: Array<PreloadData> = [];
+ let css = '';
+
+ /**
+ * Holds a list of font files to be used for optimized fallbacks generation
+ */
+ const collectedFonts: Array<CollectedFontForMetrics> = [];
+ const fallbacks = family.fallbacks ?? defaults.fallbacks ?? [];
+
+ /**
+ * Allows collecting and transforming original URLs from providers, so the Vite
+ * plugin has control over URLs.
+ */
+ const urlProxy = createUrlProxy({
+ local: family.provider === LOCAL_PROVIDER_NAME,
+ hasUrl: (hash) => fontFileDataMap.has(hash),
+ saveUrl: ({ hash, url, init }) => {
+ fontFileDataMap.set(hash, { url, init });
+ },
+ savePreload: (preload) => {
+ preloadData.push(preload);
+ },
+ saveFontData: (collected) => {
+ if (
+ fallbacks &&
+ fallbacks.length > 0 &&
+ // If the same data has already been sent for this family, we don't want to have
+ // duplicated fallbacks. Such scenario can occur with unicode ranges.
+ !collectedFonts.some((f) => JSON.stringify(f.data) === JSON.stringify(collected.data))
+ ) {
+ // If a family has fallbacks, we store the first url we get that may
+ // be used for the fallback generation.
+ collectedFonts.push(collected);
+ }
+ },
+ });
+
+ let fonts: Array<unifont.FontFaceData>;
+
+ if (family.provider === LOCAL_PROVIDER_NAME) {
+ const result = resolveLocalFont({
+ family,
+ urlProxy,
+ fontTypeExtractor,
+ fontFileReader,
+ });
+ // URLs are already proxied at this point so no further processing is required
+ fonts = result.fonts;
+ } else {
+ const result = await resolveFont(
+ family.name,
+ // We do not merge the defaults, we only provide defaults as a fallback
+ {
+ weights: family.weights ?? defaults.weights,
+ styles: family.styles ?? defaults.styles,
+ subsets: family.subsets ?? defaults.subsets,
+ fallbacks: family.fallbacks ?? defaults.fallbacks,
+ },
+ // By default, unifont goes through all providers. We use a different approach where
+ // we specify a provider per font. Name has been set while extracting unifont providers
+ // from families (inside extractUnifontProviders).
+ [family.provider.name!],
+ );
+ if (result.fonts.length === 0) {
+ logger.warn(
+ 'assets',
+ `No data found for font family ${bold(family.name)}. Review your configuration`,
+ );
+ }
+ // The data returned by the remote provider contains original URLs. We proxy them.
+ fonts = normalizeRemoteFontFaces({ fonts: result.fonts, urlProxy, fontTypeExtractor });
+ }
+
+ for (const data of fonts) {
+ css += cssRenderer.generateFontFace(
+ family.nameWithHash,
+ unifontFontFaceDataToProperties({
+ src: data.src,
+ weight: data.weight,
+ style: data.style,
+ // User settings override the generated font settings. We use a helper function
+ // because local and remote providers store this data in different places.
+ display: pickFontFaceProperty('display', { data, family }),
+ unicodeRange: pickFontFaceProperty('unicodeRange', { data, family }),
+ stretch: pickFontFaceProperty('stretch', { data, family }),
+ featureSettings: pickFontFaceProperty('featureSettings', { data, family }),
+ variationSettings: pickFontFaceProperty('variationSettings', { data, family }),
+ }),
+ );
+ }
+
+ const cssVarValues = [family.nameWithHash];
+ const optimizeFallbacksResult = await optimizeFallbacks({
+ family,
+ fallbacks,
+ collectedFonts,
+ enabled: family.optimizedFallbacks ?? defaults.optimizedFallbacks ?? false,
+ systemFallbacksProvider,
+ fontMetricsResolver,
+ });
+
+ if (optimizeFallbacksResult) {
+ css += optimizeFallbacksResult.css;
+ cssVarValues.push(...optimizeFallbacksResult.fallbacks);
+ } else {
+ // If there are no optimized fallbacks, we pass the provided fallbacks as is.
+ cssVarValues.push(...fallbacks);
+ }
+
+ css += cssRenderer.generateCssVariable(family.cssVariable, cssVarValues);
+
+ consumableMap.set(family.cssVariable, { preloadData, css });
+ }
+
+ return { fontFileDataMap, consumableMap };
+}
diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts b/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts
new file mode 100644
index 000000000..03b6a8464
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/entrypoints/adobe.ts
@@ -0,0 +1,4 @@
+import { providers } from 'unifont';
+
+// Required type annotation because its options type is not exported
+export const provider: typeof providers.adobe = providers.adobe;
diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts b/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts
new file mode 100644
index 000000000..efff38505
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/entrypoints/bunny.ts
@@ -0,0 +1,3 @@
+import { providers } from 'unifont';
+
+export const provider = providers.bunny;
diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts
new file mode 100644
index 000000000..78f676836
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/entrypoints/fontshare.ts
@@ -0,0 +1,3 @@
+import { providers } from 'unifont';
+
+export const provider = providers.fontshare;
diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts b/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts
new file mode 100644
index 000000000..25f19cc8d
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/entrypoints/fontsource.ts
@@ -0,0 +1,3 @@
+import { providers } from 'unifont';
+
+export const provider = providers.fontsource;
diff --git a/packages/astro/src/assets/fonts/providers/entrypoints/google.ts b/packages/astro/src/assets/fonts/providers/entrypoints/google.ts
new file mode 100644
index 000000000..5851dea20
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/entrypoints/google.ts
@@ -0,0 +1,4 @@
+import { providers } from 'unifont';
+
+// Required type annotation because its options type is not exported
+export const provider: typeof providers.google = providers.google;
diff --git a/packages/astro/src/assets/fonts/providers/index.ts b/packages/astro/src/assets/fonts/providers/index.ts
new file mode 100644
index 000000000..7c41a4b6d
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/index.ts
@@ -0,0 +1,60 @@
+import type { providers } from 'unifont';
+import type { AstroFontProvider } from '../types.js';
+
+/** [Adobe](https://fonts.adobe.com/) */
+function adobe(config: Parameters<typeof providers.adobe>[0]) {
+ return defineAstroFontProvider({
+ entrypoint: 'astro/assets/fonts/providers/adobe',
+ config,
+ });
+}
+
+/** [Bunny](https://fonts.bunny.net/) */
+function bunny() {
+ return defineAstroFontProvider({
+ entrypoint: 'astro/assets/fonts/providers/bunny',
+ });
+}
+
+/** [Fontshare](https://www.fontshare.com/) */
+function fontshare() {
+ return defineAstroFontProvider({
+ entrypoint: 'astro/assets/fonts/providers/fontshare',
+ });
+}
+
+/** [Fontsource](https://fontsource.org/) */
+function fontsource() {
+ return defineAstroFontProvider({
+ entrypoint: 'astro/assets/fonts/providers/fontsource',
+ });
+}
+
+/** [Google](https://fonts.google.com/) */
+function google(config?: Parameters<typeof providers.google>[0]) {
+ return defineAstroFontProvider({
+ entrypoint: 'astro/assets/fonts/providers/google',
+ config,
+ });
+}
+
+/**
+ * Astro re-exports most [unifont](https://github.com/unjs/unifont/) providers:
+ * - [Adobe](https://fonts.adobe.com/)
+ * - [Bunny](https://fonts.bunny.net/)
+ * - [Fontshare](https://www.fontshare.com/)
+ * - [Fontsource](https://fontsource.org/)
+ * - [Google](https://fonts.google.com/)
+ */
+export const fontProviders = {
+ adobe,
+ bunny,
+ fontshare,
+ fontsource,
+ google,
+};
+
+/** A type helper for defining Astro font providers config objects */
+export function defineAstroFontProvider(provider: AstroFontProvider) {
+ return provider;
+}
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 000000000..63fdf3d65
--- /dev/null
+++ b/packages/astro/src/assets/fonts/providers/local.ts
@@ -0,0 +1,70 @@
+import type * as unifont from 'unifont';
+import { FONT_FORMATS } from '../constants.js';
+import type { FontFileReader, FontTypeExtractor, UrlProxy } from '../definitions.js';
+import type { ResolvedLocalFontFamily } from '../types.js';
+
+interface Options {
+ family: ResolvedLocalFontFamily;
+ urlProxy: UrlProxy;
+ fontTypeExtractor: FontTypeExtractor;
+ fontFileReader: FontFileReader;
+}
+
+export function resolveLocalFont({
+ family,
+ urlProxy,
+ fontTypeExtractor,
+ fontFileReader,
+}: Options): {
+ fonts: Array<unifont.FontFaceData>;
+} {
+ return {
+ fonts: family.variants.map((variant) => {
+ const shouldInfer = variant.weight === undefined || variant.style === undefined;
+
+ // We prepare the data
+ const data: unifont.FontFaceData = {
+ // If it should be inferred, we don't want to set the value
+ weight: variant.weight,
+ style: variant.style,
+ src: [],
+ unicodeRange: variant.unicodeRange,
+ display: variant.display,
+ stretch: variant.stretch,
+ featureSettings: variant.featureSettings,
+ variationSettings: variant.variationSettings,
+ };
+ // We proxy each source
+ data.src = variant.src.map((source, index) => {
+ // We only try to infer for the first source. Indeed if it doesn't work, the function
+ // call will throw an error so that will be interruped anyways
+ if (shouldInfer && index === 0) {
+ const result = fontFileReader.extract({ family: family.name, url: source.url });
+ if (variant.weight === undefined) data.weight = result.weight;
+ 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,
+ data: {
+ weight: data.weight,
+ style: data.style,
+ },
+ init: null,
+ }),
+ format: FONT_FORMATS.find((e) => e.type === type)?.format,
+ tech: source.tech,
+ };
+ });
+ return data;
+ }),
+ };
+}
diff --git a/packages/astro/src/assets/fonts/sync.ts b/packages/astro/src/assets/fonts/sync.ts
new file mode 100644
index 000000000..a1af4dd70
--- /dev/null
+++ b/packages/astro/src/assets/fonts/sync.ts
@@ -0,0 +1,18 @@
+import type { AstroSettings } from '../../types/astro.js';
+import { FONTS_TYPES_FILE } from './constants.js';
+
+// TODO: investigate moving to orchestrate
+export function syncFonts(settings: AstroSettings): void {
+ if (!settings.config.experimental.fonts) {
+ return;
+ }
+
+ settings.injectedTypes.push({
+ filename: FONTS_TYPES_FILE,
+ content: `declare module 'astro:assets' {
+ /** @internal */
+ export type FontFamily = (${JSON.stringify(settings.config.experimental.fonts.map((family) => family.cssVariable))})[number];
+}
+`,
+ });
+}
diff --git a/packages/astro/src/assets/fonts/types.ts b/packages/astro/src/assets/fonts/types.ts
new file mode 100644
index 000000000..fda2971ce
--- /dev/null
+++ b/packages/astro/src/assets/fonts/types.ts
@@ -0,0 +1,106 @@
+import type { Font } from '@capsizecss/unpack';
+import type * as unifont from 'unifont';
+import type { z } from 'zod';
+import type {
+ fontProviderSchema,
+ localFontFamilySchema,
+ remoteFontFamilySchema,
+ styleSchema,
+} from './config.js';
+import type { FONT_TYPES, GENERIC_FALLBACK_NAMES } from './constants.js';
+import type { CollectedFontForMetrics } from './logic/optimize-fallbacks.js';
+
+export type AstroFontProvider = z.infer<typeof fontProviderSchema>;
+
+export interface ResolvedFontProvider {
+ name?: string;
+ provider: (config?: Record<string, any>) => unifont.Provider;
+ config?: Record<string, any>;
+}
+
+export type LocalFontFamily = z.infer<typeof localFontFamilySchema>;
+
+interface ResolvedFontFamilyAttributes {
+ nameWithHash: string;
+}
+
+export interface ResolvedLocalFontFamily
+ extends ResolvedFontFamilyAttributes,
+ Omit<LocalFontFamily, 'variants'> {
+ variants: Array<
+ Omit<LocalFontFamily['variants'][number], 'weight' | 'src'> & {
+ weight?: string;
+ src: Array<{ url: string; tech?: string }>;
+ }
+ >;
+}
+
+type RemoteFontFamily = z.infer<typeof remoteFontFamilySchema>;
+
+/** @lintignore somehow required by pickFontFaceProperty in utils */
+export interface ResolvedRemoteFontFamily
+ extends ResolvedFontFamilyAttributes,
+ Omit<z.output<typeof remoteFontFamilySchema>, 'provider' | 'weights'> {
+ provider: ResolvedFontProvider;
+ weights?: Array<string>;
+}
+
+export type FontFamily = LocalFontFamily | RemoteFontFamily;
+export type ResolvedFontFamily = ResolvedLocalFontFamily | ResolvedRemoteFontFamily;
+
+export type FontType = (typeof FONT_TYPES)[number];
+
+/**
+ * Preload data is used for links generation inside the <Font /> component
+ */
+export interface PreloadData {
+ /**
+ * Absolute link to a font file, eg. /_astro/fonts/abc.woff
+ */
+ url: string;
+ /**
+ * A font type, eg. woff2, woff, ttf...
+ */
+ type: FontType;
+}
+
+export type FontFaceMetrics = Pick<
+ Font,
+ 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
+>;
+
+export type GenericFallbackName = (typeof GENERIC_FALLBACK_NAMES)[number];
+
+export type Defaults = Partial<
+ Pick<
+ ResolvedRemoteFontFamily,
+ 'weights' | 'styles' | 'subsets' | 'fallbacks' | 'optimizedFallbacks'
+ >
+>;
+
+export interface FontFileData {
+ hash: string;
+ url: string;
+ init: RequestInit | null;
+}
+
+export interface CreateUrlProxyParams {
+ local: boolean;
+ hasUrl: (hash: string) => boolean;
+ 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 }>;
+
+export type Style = z.output<typeof styleSchema>;
diff --git a/packages/astro/src/assets/fonts/utils.ts b/packages/astro/src/assets/fonts/utils.ts
new file mode 100644
index 000000000..d56feed6d
--- /dev/null
+++ b/packages/astro/src/assets/fonts/utils.ts
@@ -0,0 +1,119 @@
+import { createRequire } from 'node:module';
+import { pathToFileURL } from 'node:url';
+import type * as unifont from 'unifont';
+import type { Storage } from 'unstorage';
+import { FONT_TYPES, GENERIC_FALLBACK_NAMES, LOCAL_PROVIDER_NAME } from './constants.js';
+import type { CssProperties } from './definitions.js';
+import type { FontType, GenericFallbackName, ResolvedFontFamily } from './types.js';
+
+/**
+ * Turns unifont font face data into generic CSS properties, to be consumed by the CSS renderer.
+ */
+export function unifontFontFaceDataToProperties(
+ font: Partial<unifont.FontFaceData>,
+): CssProperties {
+ return {
+ src: font.src ? renderFontSrc(font.src) : undefined,
+ 'font-display': font.display ?? 'swap',
+ 'unicode-range': font.unicodeRange?.length ? font.unicodeRange.join(',') : undefined,
+ 'font-weight': Array.isArray(font.weight) ? font.weight.join(' ') : font.weight?.toString(),
+ 'font-style': font.style,
+ 'font-stretch': font.stretch,
+ 'font-feature-settings': font.featureSettings,
+ 'font-variation-settings': font.variationSettings,
+ };
+}
+
+/**
+ * Turns unifont font face data src into a valid CSS property.
+ * Adapted from https://github.com/nuxt/fonts/blob/main/src/css/render.ts#L68-L81
+ */
+export function renderFontSrc(
+ sources: Exclude<unifont.FontFaceData['src'][number], string>[],
+): string {
+ return sources
+ .map((src) => {
+ if ('name' in src) {
+ return `local("${src.name}")`;
+ }
+ let rendered = `url("${src.url}")`;
+ if (src.format) {
+ rendered += ` format("${src.format}")`;
+ }
+ if (src.tech) {
+ rendered += ` tech(${src.tech})`;
+ }
+ return rendered;
+ })
+ .join(', ');
+}
+
+const QUOTES_RE = /^["']|["']$/g;
+
+/**
+ * Removes the quotes from a string. Used for family names
+ */
+export function withoutQuotes(str: string): string {
+ return str.trim().replace(QUOTES_RE, '');
+}
+
+export function isFontType(str: string): str is FontType {
+ return (FONT_TYPES as Readonly<Array<string>>).includes(str);
+}
+
+export async function cache(
+ storage: Storage,
+ key: string,
+ cb: () => Promise<Buffer>,
+): Promise<Buffer> {
+ const existing = await storage.getItemRaw(key);
+ if (existing) {
+ return existing;
+ }
+ const data = await cb();
+ await storage.setItemRaw(key, data);
+ return data;
+}
+
+export function isGenericFontFamily(str: string): str is GenericFallbackName {
+ return (GENERIC_FALLBACK_NAMES as unknown as Array<string>).includes(str);
+}
+
+export function dedupe<const T extends Array<any>>(arr: T): T {
+ return [...new Set(arr)] as T;
+}
+
+export function sortObjectByKey<T extends Record<string, any>>(unordered: T): T {
+ const ordered = Object.keys(unordered)
+ .sort()
+ .reduce((obj, key) => {
+ const value = unordered[key];
+ // @ts-expect-error Type 'T' is generic and can only be indexed for reading. That's fine here
+ obj[key] = Array.isArray(value)
+ ? value.map((v) => (typeof v === 'object' && v !== null ? sortObjectByKey(v) : v))
+ : typeof value === 'object' && value !== null
+ ? sortObjectByKey(value)
+ : value;
+ return obj;
+ }, {} as T);
+ return ordered;
+}
+
+export function resolveEntrypoint(root: URL, entrypoint: string): URL {
+ const require = createRequire(root);
+
+ try {
+ return pathToFileURL(require.resolve(entrypoint));
+ } catch {
+ return new URL(entrypoint, root);
+ }
+}
+
+export function pickFontFaceProperty<
+ T extends keyof Pick<
+ unifont.FontFaceData,
+ 'display' | 'unicodeRange' | 'stretch' | 'featureSettings' | 'variationSettings'
+ >,
+>(property: T, { data, family }: { data: unifont.FontFaceData; family: ResolvedFontFamily }) {
+ return data[property] ?? (family.provider === LOCAL_PROVIDER_NAME ? undefined : family[property]);
+}
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 000000000..cdfe4a055
--- /dev/null
+++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts
@@ -0,0 +1,305 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { Plugin } from 'vite';
+import { collectErrorMetadata } from '../../core/errors/dev/utils.js';
+import { AstroError, AstroErrorData, isAstroError } from '../../core/errors/index.js';
+import type { Logger } from '../../core/logger/core.js';
+import { formatErrorMessage } from '../../core/messages.js';
+import { appendForwardSlash, joinPaths, prependForwardSlash } from '../../core/path.js';
+import { getClientOutputDirectory } from '../../prerender/utils.js';
+import type { AstroSettings } from '../../types/astro.js';
+import {
+ ASSETS_DIR,
+ CACHE_DIR,
+ DEFAULTS,
+ RESOLVED_VIRTUAL_MODULE_ID,
+ VIRTUAL_MODULE_ID,
+} from './constants.js';
+import type {
+ CssRenderer,
+ FontFetcher,
+ FontTypeExtractor,
+ RemoteFontProviderModResolver,
+ UrlResolver,
+} from './definitions.js';
+import { createMinifiableCssRenderer } from './implementations/css-renderer.js';
+import { createDataCollector } from './implementations/data-collector.js';
+import { createAstroErrorHandler } from './implementations/error-handler.js';
+import { createCachedFontFetcher } from './implementations/font-fetcher.js';
+import { createFontaceFontFileReader } from './implementations/font-file-reader.js';
+import { createCapsizeFontMetricsResolver } from './implementations/font-metrics-resolver.js';
+import { createFontTypeExtractor } from './implementations/font-type-extractor.js';
+import { createXxHasher } from './implementations/hasher.js';
+import { createRequireLocalProviderUrlResolver } from './implementations/local-provider-url-resolver.js';
+import {
+ createBuildRemoteFontProviderModResolver,
+ createDevServerRemoteFontProviderModResolver,
+} from './implementations/remote-font-provider-mod-resolver.js';
+import { createRemoteFontProviderResolver } from './implementations/remote-font-provider-resolver.js';
+import { createFsStorage } from './implementations/storage.js';
+import { createSystemFallbacksProvider } from './implementations/system-fallbacks-provider.js';
+import {
+ createLocalUrlProxyContentResolver,
+ createRemoteUrlProxyContentResolver,
+} from './implementations/url-proxy-content-resolver.js';
+import { createUrlProxy } from './implementations/url-proxy.js';
+import { createBuildUrlResolver, createDevUrlResolver } from './implementations/url-resolver.js';
+import { orchestrate } from './orchestrate.js';
+import type { ConsumableMap, FontFileDataMap } from './types.js';
+
+interface Options {
+ settings: AstroSettings;
+ sync: boolean;
+ logger: Logger;
+}
+
+export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
+ if (!settings.config.experimental.fonts) {
+ // This is required because the virtual module may be imported as
+ // a side effect
+ // TODO: remove once fonts are stabilized
+ return {
+ name: 'astro:fonts:fallback',
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ load(id) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ return {
+ code: '',
+ };
+ }
+ },
+ };
+ }
+
+ // We don't need to worry about config.trailingSlash because we are dealing with
+ // static assets only, ie. trailingSlash: 'never'
+ const assetsDir = prependForwardSlash(
+ appendForwardSlash(joinPaths(settings.config.build.assets, ASSETS_DIR)),
+ );
+ const baseUrl = joinPaths(settings.config.base, assetsDir);
+
+ 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 = () => {
+ consumableMap = null;
+ fontFileDataMap = null;
+ fontFetcher = null;
+ };
+
+ async function initialize({
+ cacheDir,
+ modResolver,
+ cssRenderer,
+ urlResolver,
+ }: {
+ cacheDir: URL;
+ modResolver: RemoteFontProviderModResolver;
+ cssRenderer: CssRenderer;
+ urlResolver: UrlResolver;
+ }) {
+ const { root } = settings.config;
+ // Dependencies. Once extracted to a dedicated vite plugin, those may be passed as
+ // a Vite plugin option.
+ const hasher = await createXxHasher();
+ const errorHandler = createAstroErrorHandler();
+ const remoteFontProviderResolver = createRemoteFontProviderResolver({
+ root,
+ modResolver,
+ errorHandler,
+ });
+ // TODO: remove when stabilizing
+ const pathsToWarn = new Set<string>();
+ const localProviderUrlResolver = createRequireLocalProviderUrlResolver({
+ root,
+ intercept: (path) => {
+ if (path.startsWith(fileURLToPath(settings.config.publicDir))) {
+ if (pathsToWarn.has(path)) {
+ return;
+ }
+ pathsToWarn.add(path);
+ logger.warn(
+ 'assets',
+ `Found a local font file ${JSON.stringify(path)} in the \`public/\` folder. To avoid duplicated files in the build output, move this file into \`src/\``,
+ );
+ }
+ },
+ });
+ const storage = createFsStorage({ base: cacheDir });
+ const systemFallbacksProvider = createSystemFallbacksProvider();
+ fontFetcher = createCachedFontFetcher({ storage, errorHandler, fetch, readFile });
+ const fontMetricsResolver = createCapsizeFontMetricsResolver({ fontFetcher, cssRenderer });
+ fontTypeExtractor = createFontTypeExtractor({ errorHandler });
+ const fontFileReader = createFontaceFontFileReader({ errorHandler });
+
+ const res = await orchestrate({
+ families: settings.config.experimental.fonts!,
+ hasher,
+ remoteFontProviderResolver,
+ localProviderUrlResolver,
+ storage,
+ cssRenderer,
+ systemFallbacksProvider,
+ fontMetricsResolver,
+ fontTypeExtractor,
+ fontFileReader,
+ logger,
+ createUrlProxy: ({ local, ...params }) => {
+ const dataCollector = createDataCollector(params);
+ const contentResolver = local
+ ? createLocalUrlProxyContentResolver({ errorHandler })
+ : createRemoteUrlProxyContentResolver();
+ return createUrlProxy({
+ urlResolver,
+ contentResolver,
+ hasher,
+ dataCollector,
+ });
+ },
+ defaults: DEFAULTS,
+ });
+ // We initialize shared variables here and reset them in buildEnd
+ // to avoid locking memory
+ fontFileDataMap = res.fontFileDataMap;
+ consumableMap = res.consumableMap;
+ }
+
+ return {
+ name: 'astro:fonts',
+ config(_, { command }) {
+ isBuild = command === 'build';
+ },
+ async buildStart() {
+ if (isBuild) {
+ await initialize({
+ cacheDir: new URL(CACHE_DIR, settings.config.cacheDir),
+ modResolver: createBuildRemoteFontProviderModResolver(),
+ cssRenderer: createMinifiableCssRenderer({ minify: true }),
+ urlResolver: createBuildUrlResolver({
+ base: baseUrl,
+ assetsPrefix: settings.config.build.assetsPrefix,
+ }),
+ });
+ }
+ },
+ async configureServer(server) {
+ await initialize({
+ // In dev, we cache fonts data in .astro so it can be easily inspected and cleared
+ cacheDir: new URL(CACHE_DIR, settings.dotAstroDir),
+ modResolver: createDevServerRemoteFontProviderModResolver({ server }),
+ cssRenderer: createMinifiableCssRenderer({ minify: false }),
+ urlResolver: createDevUrlResolver({ base: baseUrl }),
+ });
+ // 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 = [...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');
+ server.restart();
+ }
+ });
+ // We do not purge the cache in case the user wants to re-use the file later on
+ server.watcher.on('unlink', (path) => {
+ if (localPaths.includes(path)) {
+ logger.warn(
+ 'assets',
+ `The font file ${JSON.stringify(path)} referenced in your config has been deleted. Restore the file or remove this font from your configuration if it is no longer needed.`,
+ );
+ }
+ });
+
+ server.middlewares.use(assetsDir, async (req, res, next) => {
+ if (!req.url) {
+ return next();
+ }
+ const hash = req.url.slice(1);
+ 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,
+ // and it makes it easier to debug without needing hard refreshes
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
+ res.setHeader('Pragma', 'no-cache');
+ res.setHeader('Expires', 0);
+
+ try {
+ // 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, ...associatedData });
+
+ res.setHeader('Content-Length', data.length);
+ res.setHeader('Content-Type', `font/${fontTypeExtractor!.extract(hash)}`);
+
+ res.end(data);
+ } catch (err) {
+ logger.error('assets', 'Cannot download font file');
+ if (isAstroError(err)) {
+ logger.error(
+ 'SKIP_FORMAT',
+ formatErrorMessage(collectErrorMetadata(err), logger.level() === 'debug'),
+ );
+ }
+ res.statusCode = 500;
+ res.end();
+ }
+ });
+ },
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ load(id) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ return {
+ code: `export const fontsData = new Map(${JSON.stringify(Array.from(consumableMap?.entries() ?? []))})`,
+ };
+ }
+ },
+ async buildEnd() {
+ if (sync || settings.config.experimental.fonts!.length === 0) {
+ cleanup();
+ return;
+ }
+
+ try {
+ const dir = getClientOutputDirectory(settings);
+ const fontsDir = new URL(`.${assetsDir}`, dir);
+ try {
+ mkdirSync(fontsDir, { recursive: true });
+ } catch (cause) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause });
+ }
+ if (fontFileDataMap) {
+ logger.info('assets', 'Copying fonts...');
+ await Promise.all(
+ Array.from(fontFileDataMap.entries()).map(async ([hash, associatedData]) => {
+ const data = await fontFetcher!.fetch({ hash, ...associatedData });
+ try {
+ writeFileSync(new URL(hash, fontsDir), data);
+ } catch (cause) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause });
+ }
+ }),
+ );
+ }
+ } finally {
+ cleanup();
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/assets/index.ts b/packages/astro/src/assets/index.ts
new file mode 100644
index 000000000..9eeccf250
--- /dev/null
+++ b/packages/astro/src/assets/index.ts
@@ -0,0 +1,3 @@
+export { getConfiguredImageService, getImage } from './internal.js';
+export { baseService, isLocalService } from './services/service.js';
+export { type LocalImageProps, type RemoteImageProps } from './types.js';
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
new file mode 100644
index 000000000..a88276e2e
--- /dev/null
+++ b/packages/astro/src/assets/internal.ts
@@ -0,0 +1,233 @@
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { AstroConfig } from '../types/public/config.js';
+import { DEFAULT_HASH_PROPS } from './consts.js';
+import {
+ DEFAULT_RESOLUTIONS,
+ LIMITED_RESOLUTIONS,
+ getSizesAttribute,
+ getWidths,
+} from './layout.js';
+import { type ImageService, isLocalService } from './services/service.js';
+import {
+ type GetImageResult,
+ type ImageTransform,
+ type SrcSetValue,
+ type UnresolvedImageTransform,
+ isImageMetadata,
+} from './types.js';
+import { addCSSVarsToStyle, cssFitValues } from './utils/imageAttributes.js';
+import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
+import { inferRemoteSize } from './utils/remoteProbe.js';
+
+export async function getConfiguredImageService(): Promise<ImageService> {
+ if (!globalThis?.astroAsset?.imageService) {
+ const { default: service }: { default: ImageService } = await import(
+ // @ts-expect-error
+ 'virtual:image-service'
+ ).catch((e) => {
+ const error = new AstroError(AstroErrorData.InvalidImageService);
+ error.cause = e;
+ throw error;
+ });
+
+ if (!globalThis.astroAsset) globalThis.astroAsset = {};
+ globalThis.astroAsset.imageService = service;
+ return service;
+ }
+
+ return globalThis.astroAsset.imageService;
+}
+
+type ImageConfig = AstroConfig['image'] & {
+ experimentalResponsiveImages: boolean;
+};
+
+export async function getImage(
+ options: UnresolvedImageTransform,
+ imageConfig: ImageConfig,
+): Promise<GetImageResult> {
+ if (!options || typeof options !== 'object') {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImageOptions,
+ message: AstroErrorData.ExpectedImageOptions.message(JSON.stringify(options)),
+ });
+ }
+ if (typeof options.src === 'undefined') {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImage,
+ message: AstroErrorData.ExpectedImage.message(
+ options.src,
+ 'undefined',
+ JSON.stringify(options),
+ ),
+ });
+ }
+
+ if (isImageMetadata(options)) {
+ throw new AstroError(AstroErrorData.ExpectedNotESMImage);
+ }
+
+ const service = await getConfiguredImageService();
+
+ // If the user inlined an import, something fairly common especially in MDX, or passed a function that returns an Image, await it for them
+ const resolvedOptions: ImageTransform = {
+ ...options,
+ src: await resolveSrc(options.src),
+ };
+
+ let originalWidth: number | undefined;
+ let originalHeight: number | undefined;
+ let originalFormat: string | undefined;
+
+ // Infer size for remote images if inferSize is true
+ if (
+ options.inferSize &&
+ isRemoteImage(resolvedOptions.src) &&
+ isRemotePath(resolvedOptions.src)
+ ) {
+ const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
+ resolvedOptions.width ??= result.width;
+ resolvedOptions.height ??= result.height;
+ originalWidth = result.width;
+ originalHeight = result.height;
+ originalFormat = result.format;
+ delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes
+ }
+
+ const originalFilePath = isESMImportedImage(resolvedOptions.src)
+ ? resolvedOptions.src.fsPath
+ : undefined; // Only set for ESM imports, where we do have a file path
+
+ // Clone the `src` object if it's an ESM import so that we don't refer to any properties of the original object
+ // Causing our generate step to think the image is used outside of the image optimization pipeline
+ const clonedSrc = isESMImportedImage(resolvedOptions.src)
+ ? // @ts-expect-error - clone is a private, hidden prop
+ (resolvedOptions.src.clone ?? resolvedOptions.src)
+ : resolvedOptions.src;
+
+ if (isESMImportedImage(clonedSrc)) {
+ originalWidth = clonedSrc.width;
+ originalHeight = clonedSrc.height;
+ originalFormat = clonedSrc.format;
+ }
+
+ if (originalWidth && originalHeight) {
+ // Calculate any missing dimensions from the aspect ratio, if available
+ const aspectRatio = originalWidth / originalHeight;
+ if (resolvedOptions.height && !resolvedOptions.width) {
+ resolvedOptions.width = Math.round(resolvedOptions.height * aspectRatio);
+ } else if (resolvedOptions.width && !resolvedOptions.height) {
+ resolvedOptions.height = Math.round(resolvedOptions.width / aspectRatio);
+ } else if (!resolvedOptions.width && !resolvedOptions.height) {
+ resolvedOptions.width = originalWidth;
+ resolvedOptions.height = originalHeight;
+ }
+ }
+ resolvedOptions.src = clonedSrc;
+
+ const layout = options.layout ?? imageConfig.experimentalLayout;
+
+ if (imageConfig.experimentalResponsiveImages && layout) {
+ resolvedOptions.widths ||= getWidths({
+ width: resolvedOptions.width,
+ layout,
+ originalWidth,
+ breakpoints: imageConfig.experimentalBreakpoints?.length
+ ? imageConfig.experimentalBreakpoints
+ : isLocalService(service)
+ ? LIMITED_RESOLUTIONS
+ : DEFAULT_RESOLUTIONS,
+ });
+ resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });
+
+ if (resolvedOptions.priority) {
+ resolvedOptions.loading ??= 'eager';
+ resolvedOptions.decoding ??= 'sync';
+ resolvedOptions.fetchpriority ??= 'high';
+ } else {
+ resolvedOptions.loading ??= 'lazy';
+ resolvedOptions.decoding ??= 'async';
+ resolvedOptions.fetchpriority ??= 'auto';
+ }
+ delete resolvedOptions.priority;
+ delete resolvedOptions.densities;
+
+ if (layout !== 'none') {
+ resolvedOptions.style = addCSSVarsToStyle(
+ {
+ fit: cssFitValues.includes(resolvedOptions.fit ?? '') && resolvedOptions.fit,
+ pos: resolvedOptions.position,
+ },
+ resolvedOptions.style,
+ );
+ resolvedOptions['data-astro-image'] = layout;
+ }
+ }
+
+ const validatedOptions = service.validateOptions
+ ? await service.validateOptions(resolvedOptions, imageConfig)
+ : resolvedOptions;
+
+ // Get all the options for the different srcSets
+ const srcSetTransforms = service.getSrcSet
+ ? await service.getSrcSet(validatedOptions, imageConfig)
+ : [];
+
+ let imageURL = await service.getURL(validatedOptions, imageConfig);
+
+ const matchesOriginal = (transform: ImageTransform) =>
+ transform.width === originalWidth &&
+ transform.height === originalHeight &&
+ transform.format === originalFormat;
+
+ let srcSets: SrcSetValue[] = await Promise.all(
+ srcSetTransforms.map(async (srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : await service.getURL(srcSet.transform, imageConfig),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ }),
+ );
+
+ if (
+ isLocalService(service) &&
+ globalThis.astroAsset.addStaticImage &&
+ !(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
+ ) {
+ const propsToHash = service.propertiesToHash ?? DEFAULT_HASH_PROPS;
+ imageURL = globalThis.astroAsset.addStaticImage(
+ validatedOptions,
+ propsToHash,
+ originalFilePath,
+ );
+ srcSets = srcSetTransforms.map((srcSet) => {
+ return {
+ transform: srcSet.transform,
+ url: matchesOriginal(srcSet.transform)
+ ? imageURL
+ : globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
+ descriptor: srcSet.descriptor,
+ attributes: srcSet.attributes,
+ };
+ });
+ }
+
+ return {
+ rawOptions: resolvedOptions,
+ options: validatedOptions,
+ src: imageURL,
+ srcSet: {
+ values: srcSets,
+ attribute: srcSets.map((srcSet) => `${srcSet.url} ${srcSet.descriptor}`).join(', '),
+ },
+ attributes:
+ service.getHTMLAttributes !== undefined
+ ? await service.getHTMLAttributes(validatedOptions, imageConfig)
+ : {},
+ };
+}
diff --git a/packages/astro/src/assets/layout.ts b/packages/astro/src/assets/layout.ts
new file mode 100644
index 000000000..ea0be6f74
--- /dev/null
+++ b/packages/astro/src/assets/layout.ts
@@ -0,0 +1,118 @@
+import type { ImageLayout } from './types.js';
+
+// Common screen widths. These will be filtered according to the image size and layout
+export const DEFAULT_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 960, // older horizontal phones
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 1920, // 1080p
+ 2048, // QXGA
+ 2560, // WQXGA
+ 3200, // QHD+
+ 3840, // 4K
+ 4480, // 4.5K
+ 5120, // 5K
+ 6016, // 6K
+];
+
+// A more limited set of screen widths, for statically generated images
+export const LIMITED_RESOLUTIONS = [
+ 640, // older and lower-end phones
+ 750, // iPhone 6-8
+ 828, // iPhone XR/11
+ 1080, // iPhone 6-8 Plus
+ 1280, // 720p
+ 1668, // Various iPads
+ 2048, // QXGA
+ 2560, // WQXGA
+];
+
+/**
+ * Gets the breakpoints for an image, based on the layout and width
+ *
+ * The rules are as follows:
+ *
+ * - For full-width layout we return all breakpoints smaller than the original image width
+ * - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that.
+ * - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that.
+ */
+export const getWidths = ({
+ width,
+ layout,
+ breakpoints = DEFAULT_RESOLUTIONS,
+ originalWidth,
+}: {
+ width?: number;
+ layout: ImageLayout;
+ breakpoints?: Array<number>;
+ originalWidth?: number;
+}): Array<number> => {
+ const smallerThanOriginal = (w: number) => !originalWidth || w <= originalWidth;
+
+ // For full-width layout we return all breakpoints smaller than the original image width
+ if (layout === 'full-width') {
+ return breakpoints.filter(smallerThanOriginal);
+ }
+ // For other layouts we need a width to generate breakpoints. If no width is provided, we return an empty array
+ if (!width) {
+ return [];
+ }
+ const doubleWidth = width * 2;
+ // For fixed layout we want to return the 1x and 2x widths. We only do this if the original image is large enough to do this though.
+ const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth;
+ if (layout === 'fixed') {
+ return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize];
+ }
+
+ // For constrained layout we want to return all breakpoints smaller than 2x requested width.
+ if (layout === 'constrained') {
+ return (
+ [
+ // Always include the image at 1x and 2x the specified width
+ width,
+ doubleWidth,
+ ...breakpoints,
+ ]
+ // Filter out any resolutions that are larger than the double-resolution image or source image
+ .filter((w) => w <= maxSize)
+ // Sort the resolutions in ascending order
+ .sort((a, b) => a - b)
+ );
+ }
+
+ return [];
+};
+
+/**
+ * Gets the `sizes` attribute for an image, based on the layout and width
+ */
+export const getSizesAttribute = ({
+ width,
+ layout,
+}: { width?: number; layout?: ImageLayout }): string | undefined => {
+ if (!width || !layout) {
+ return undefined;
+ }
+ switch (layout) {
+ // If screen is wider than the max size then image width is the max size,
+ // otherwise it's the width of the screen
+ case 'constrained':
+ return `(min-width: ${width}px) ${width}px, 100vw`;
+
+ // Image is always the same width, whatever the size of the screen
+ case 'fixed':
+ return `${width}px`;
+
+ // Image is always the width of the screen
+ case 'full-width':
+ return `100vw`;
+
+ case 'none':
+ default:
+ return undefined;
+ }
+};
diff --git a/packages/astro/src/assets/runtime.ts b/packages/astro/src/assets/runtime.ts
new file mode 100644
index 000000000..33b12378c
--- /dev/null
+++ b/packages/astro/src/assets/runtime.ts
@@ -0,0 +1,60 @@
+import {
+ createComponent,
+ render,
+ spreadAttributes,
+ unescapeHTML,
+} from '../runtime/server/index.js';
+import type { ImageMetadata } from './types.js';
+
+export interface SvgComponentProps {
+ meta: ImageMetadata;
+ attributes: Record<string, string>;
+ children: string;
+}
+
+export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
+ const Component = createComponent((_, props) => {
+ const normalizedProps = normalizeProps(attributes, props);
+
+ return render`<svg${spreadAttributes(normalizedProps)}>${unescapeHTML(children)}</svg>`;
+ });
+
+ if (import.meta.env.DEV) {
+ // Prevent revealing that this is a component
+ makeNonEnumerable(Component);
+
+ // Maintaining the current `console.log` output for SVG imports
+ Object.defineProperty(Component, Symbol.for('nodejs.util.inspect.custom'), {
+ value: (_: any, opts: any, inspect: any) => inspect(meta, opts),
+ });
+ }
+
+ // Attaching the metadata to the component to maintain current functionality
+ return Object.assign(Component, meta);
+}
+
+type SvgAttributes = Record<string, any>;
+
+/**
+ * Some attributes required for `image/svg+xml` are irrelevant when inlined in a `text/html` document. We can save a few bytes by dropping them.
+ */
+const ATTRS_TO_DROP = ['xmlns', 'xmlns:xlink', 'version'];
+const DEFAULT_ATTRS: SvgAttributes = {};
+
+export function dropAttributes(attributes: SvgAttributes) {
+ for (const attr of ATTRS_TO_DROP) {
+ delete attributes[attr];
+ }
+
+ return attributes;
+}
+
+function normalizeProps(attributes: SvgAttributes, props: SvgAttributes) {
+ return dropAttributes({ ...DEFAULT_ATTRS, ...attributes, ...props });
+}
+
+function makeNonEnumerable(object: Record<string, any>) {
+ for (const property in object) {
+ Object.defineProperty(object, property, { enumerable: false });
+ }
+}
diff --git a/packages/astro/src/assets/services/noop.ts b/packages/astro/src/assets/services/noop.ts
new file mode 100644
index 000000000..b0fe51733
--- /dev/null
+++ b/packages/astro/src/assets/services/noop.ts
@@ -0,0 +1,15 @@
+import { type LocalImageService, baseService } from './service.js';
+
+// Empty service used for platforms that don't support Sharp / users who don't want transformations.
+const noopService: LocalImageService = {
+ ...baseService,
+ propertiesToHash: ['src'],
+ async transform(inputBuffer, transformOptions) {
+ return {
+ data: inputBuffer,
+ format: transformOptions.format,
+ };
+ },
+};
+
+export default noopService;
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
new file mode 100644
index 000000000..7fa062188
--- /dev/null
+++ b/packages/astro/src/assets/services/service.ts
@@ -0,0 +1,428 @@
+import { isRemoteAllowed } from '@astrojs/internal-helpers/remote';
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import { isRemotePath, joinPaths } from '../../core/path.js';
+import type { AstroConfig } from '../../types/public/config.js';
+import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
+import type {
+ ImageFit,
+ ImageOutputFormat,
+ ImageTransform,
+ UnresolvedSrcSetValue,
+} from '../types.js';
+import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
+
+export type ImageService = LocalImageService | ExternalImageService;
+
+export function isLocalService(service: ImageService | undefined): service is LocalImageService {
+ if (!service) {
+ return false;
+ }
+
+ return 'transform' in service;
+}
+
+export function parseQuality(quality: string): string | number {
+ let result = parseInt(quality);
+ if (Number.isNaN(result)) {
+ return quality;
+ }
+
+ return result;
+}
+
+type ImageConfig<T> = Omit<AstroConfig['image'], 'service'> & {
+ service: { entrypoint: string; config: T };
+};
+
+interface SharedServiceProps<T extends Record<string, any> = Record<string, any>> {
+ /**
+ * Return the URL to the endpoint or URL your images are generated from.
+ *
+ * For a local service, your service should expose an endpoint handling the image requests, or use Astro's which by default, is located at `/_image`.
+ *
+ * For external services, this should point to the URL your images are coming from, for instance, `/_vercel/image`
+ *
+ */
+ getURL: (options: ImageTransform, imageConfig: ImageConfig<T>) => string | Promise<string>;
+ /**
+ * Generate additional `srcset` values for the image.
+ *
+ * While in most cases this is exclusively used for `srcset`, it can also be used in a more generic way to generate
+ * multiple variants of the same image. For instance, you can use this to generate multiple aspect ratios or multiple formats.
+ */
+ getSrcSet?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => UnresolvedSrcSetValue[] | Promise<UnresolvedSrcSetValue[]>;
+ /**
+ * Return any additional HTML attributes separate from `src` that your service requires to show the image properly.
+ *
+ * For example, you might want to return the `width` and `height` to avoid CLS, or a particular `class` or `style`.
+ * In most cases, you'll want to return directly what your user supplied you, minus the attributes that were used to generate the image.
+ */
+ getHTMLAttributes?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => Record<string, any> | Promise<Record<string, any>>;
+ /**
+ * Validate and return the options passed by the user.
+ *
+ * This method is useful to present errors to users who have entered invalid options.
+ * For instance, if they are missing a required property or have entered an invalid image format.
+ *
+ * This method should returns options, and can be used to set defaults (ex: a default output format to be used if the user didn't specify one.)
+ */
+ validateOptions?: (
+ options: ImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => ImageTransform | Promise<ImageTransform>;
+}
+
+export type ExternalImageService<T extends Record<string, any> = Record<string, any>> =
+ SharedServiceProps<T>;
+
+type LocalImageTransform = {
+ src: string;
+ [key: string]: any;
+};
+
+export interface LocalImageService<T extends Record<string, any> = Record<string, any>>
+ extends SharedServiceProps<T> {
+ /**
+ * Parse the requested parameters passed in the URL from `getURL` back into an object to be used later by `transform`.
+ *
+ * In most cases, this will get query parameters using, for example, `params.get('width')` and return those.
+ */
+ parseURL: (
+ url: URL,
+ imageConfig: ImageConfig<T>,
+ ) => LocalImageTransform | undefined | Promise<LocalImageTransform> | Promise<undefined>;
+ /**
+ * Performs the image transformations on the input image and returns both the binary data and
+ * final image format of the optimized image.
+ */
+ transform: (
+ inputBuffer: Uint8Array,
+ transform: LocalImageTransform,
+ imageConfig: ImageConfig<T>,
+ ) => Promise<{ data: Uint8Array; format: ImageOutputFormat }>;
+
+ /**
+ * A list of properties that should be used to generate the hash for the image.
+ *
+ * Generally, this should be all the properties that can change the result of the image. By default, this is `src`, `width`, `height`, `quality`, and `format`.
+ */
+ propertiesToHash?: string[];
+}
+
+export type BaseServiceTransform = {
+ src: string;
+ width?: number;
+ height?: number;
+ format: string;
+ quality?: string | null;
+ fit?: ImageFit;
+ position?: string;
+};
+
+const sortNumeric = (a: number, b: number) => a - b;
+
+/**
+ * Basic local service using the included `_image` endpoint.
+ * This service intentionally does not implement `transform`.
+ *
+ * Example usage:
+ * ```ts
+ * const service = {
+ * getURL: baseService.getURL,
+ * parseURL: baseService.parseURL,
+ * getHTMLAttributes: baseService.getHTMLAttributes,
+ * async transform(inputBuffer, transformOptions) {...}
+ * }
+ * ```
+ *
+ * This service adhere to the included services limitations:
+ * - Remote images are passed as is.
+ * - Only a limited amount of formats are supported.
+ * - For remote images, `width` and `height` are always required.
+ *
+ */
+export const baseService: Omit<LocalImageService, 'transform'> = {
+ propertiesToHash: DEFAULT_HASH_PROPS,
+ validateOptions(options) {
+ // `src` is missing or is `undefined`.
+ if (!options.src || (!isRemoteImage(options.src) && !isESMImportedImage(options.src))) {
+ throw new AstroError({
+ ...AstroErrorData.ExpectedImage,
+ message: AstroErrorData.ExpectedImage.message(
+ JSON.stringify(options.src),
+ typeof options.src,
+ JSON.stringify(options, (_, v) => (v === undefined ? null : v)),
+ ),
+ });
+ }
+
+ if (!isESMImportedImage(options.src)) {
+ // User passed an `/@fs/` path or a filesystem path instead of the full image.
+ if (
+ options.src.startsWith('/@fs/') ||
+ (!isRemotePath(options.src) && !options.src.startsWith('/'))
+ ) {
+ throw new AstroError({
+ ...AstroErrorData.LocalImageUsedWrongly,
+ message: AstroErrorData.LocalImageUsedWrongly.message(options.src),
+ });
+ }
+
+ // For remote images, width and height are explicitly required as we can't infer them from the file
+ let missingDimension: 'width' | 'height' | 'both' | undefined;
+ if (!options.width && !options.height) {
+ missingDimension = 'both';
+ } else if (!options.width && options.height) {
+ missingDimension = 'width';
+ } else if (options.width && !options.height) {
+ missingDimension = 'height';
+ }
+
+ if (missingDimension) {
+ throw new AstroError({
+ ...AstroErrorData.MissingImageDimension,
+ message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src),
+ });
+ }
+ } else {
+ if (!VALID_SUPPORTED_FORMATS.includes(options.src.format as any)) {
+ throw new AstroError({
+ ...AstroErrorData.UnsupportedImageFormat,
+ message: AstroErrorData.UnsupportedImageFormat.message(
+ options.src.format,
+ options.src.src,
+ VALID_SUPPORTED_FORMATS,
+ ),
+ });
+ }
+
+ if (options.widths && options.densities) {
+ throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions);
+ }
+
+ // We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one
+ if (options.src.format === 'svg') {
+ options.format = 'svg';
+ }
+
+ if (
+ (options.src.format === 'svg' && options.format !== 'svg') ||
+ (options.src.format !== 'svg' && options.format === 'svg')
+ ) {
+ throw new AstroError(AstroErrorData.UnsupportedImageConversion);
+ }
+ }
+
+ // If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality
+ // In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
+ if (!options.format) {
+ options.format = DEFAULT_OUTPUT_FORMAT;
+ }
+
+ // Sometimes users will pass number generated from division, which can result in floating point numbers
+ if (options.width) options.width = Math.round(options.width);
+ if (options.height) options.height = Math.round(options.height);
+ if (options.layout && options.width && options.height) {
+ options.fit ??= 'cover';
+ delete options.layout;
+ }
+ if (options.fit === 'none') {
+ delete options.fit;
+ }
+ return options;
+ },
+ getHTMLAttributes(options) {
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const {
+ src,
+ width,
+ height,
+ format,
+ quality,
+ densities,
+ widths,
+ formats,
+ layout,
+ priority,
+ fit,
+ position,
+ ...attributes
+ } = options;
+ return {
+ ...attributes,
+ width: targetWidth,
+ height: targetHeight,
+ loading: attributes.loading ?? 'lazy',
+ decoding: attributes.decoding ?? 'async',
+ };
+ },
+ getSrcSet(options): Array<UnresolvedSrcSetValue> {
+ const { targetWidth, targetHeight } = getTargetDimensions(options);
+ const aspectRatio = targetWidth / targetHeight;
+ const { widths, densities } = options;
+ const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
+
+ let transformedWidths = (widths ?? []).sort(sortNumeric);
+
+ // For remote images, we don't know the original image's dimensions, so we cannot know the maximum width
+ // It is ultimately the user's responsibility to make sure they don't request images larger than the original
+ let imageWidth = options.width;
+ let maxWidth = Infinity;
+
+ // However, if it's an imported image, we can use the original image's width as a maximum width
+ if (isESMImportedImage(options.src)) {
+ imageWidth = options.src.width;
+ maxWidth = imageWidth;
+
+ // We've already sorted the widths, so we'll remove any that are larger than the original image's width
+ if (transformedWidths.length > 0 && transformedWidths.at(-1)! > maxWidth) {
+ transformedWidths = transformedWidths.filter((width) => width <= maxWidth);
+ // If we've had to remove some widths, we'll add the maximum width back in
+ transformedWidths.push(maxWidth);
+ }
+ }
+
+ // Dedupe the widths
+ transformedWidths = Array.from(new Set(transformedWidths));
+
+ // Since `widths` and `densities` ultimately control the width and height of the image,
+ // we don't want the dimensions the user specified, we'll create those ourselves.
+ const {
+ width: transformWidth,
+ height: transformHeight,
+ ...transformWithoutDimensions
+ } = options;
+
+ // Collect widths to generate from specified densities or widths
+ let allWidths: Array<{
+ width: number;
+ descriptor: `${number}x` | `${number}w`;
+ }> = [];
+ if (densities) {
+ // Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers
+ const densityValues = densities.map((density) => {
+ if (typeof density === 'number') {
+ return density;
+ } else {
+ return parseFloat(density);
+ }
+ });
+
+ // Calculate the widths for each density, rounding to avoid floats.
+ const densityWidths = densityValues
+ .sort(sortNumeric)
+ .map((density) => Math.round(targetWidth * density));
+
+ allWidths = densityWidths.map((width, index) => ({
+ width,
+ descriptor: `${densityValues[index]}x`,
+ }));
+ } else if (transformedWidths.length > 0) {
+ allWidths = transformedWidths.map((width) => ({
+ width,
+ descriptor: `${width}w`,
+ }));
+ }
+
+ return allWidths.map(({ width, descriptor }) => {
+ const height = Math.round(width / aspectRatio);
+ const transform = { ...transformWithoutDimensions, width, height };
+ return {
+ transform,
+ descriptor,
+ attributes: {
+ type: `image/${targetFormat}`,
+ },
+ };
+ });
+ },
+ getURL(options, imageConfig) {
+ const searchParams = new URLSearchParams();
+
+ if (isESMImportedImage(options.src)) {
+ searchParams.append('href', options.src.src);
+ } else if (isRemoteAllowed(options.src, imageConfig)) {
+ searchParams.append('href', options.src);
+ } else {
+ // If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL
+ return options.src;
+ }
+
+ const params: Record<string, keyof typeof options> = {
+ w: 'width',
+ h: 'height',
+ q: 'quality',
+ f: 'format',
+ fit: 'fit',
+ position: 'position',
+ };
+
+ Object.entries(params).forEach(([param, key]) => {
+ options[key] && searchParams.append(param, options[key].toString());
+ });
+
+ const imageEndpoint = joinPaths(import.meta.env.BASE_URL, imageConfig.endpoint.route);
+ return `${imageEndpoint}?${searchParams}`;
+ },
+ parseURL(url) {
+ const params = url.searchParams;
+
+ if (!params.has('href')) {
+ return undefined;
+ }
+
+ const transform: BaseServiceTransform = {
+ src: params.get('href')!,
+ width: params.has('w') ? parseInt(params.get('w')!) : undefined,
+ height: params.has('h') ? parseInt(params.get('h')!) : undefined,
+ format: params.get('f') as ImageOutputFormat,
+ quality: params.get('q'),
+ fit: params.get('fit') as ImageFit,
+ position: params.get('position') ?? undefined,
+ };
+
+ return transform;
+ },
+};
+
+/**
+ * Returns the final dimensions of an image based on the user's options.
+ *
+ * For local images:
+ * - If the user specified both width and height, we'll use those.
+ * - If the user specified only one of them, we'll use the original image's aspect ratio to calculate the other.
+ * - If the user didn't specify either, we'll use the original image's dimensions.
+ *
+ * For remote images:
+ * - Widths and heights are always required, so we'll use the user's specified width and height.
+ */
+function getTargetDimensions(options: ImageTransform) {
+ let targetWidth = options.width;
+ let targetHeight = options.height;
+ if (isESMImportedImage(options.src)) {
+ const aspectRatio = options.src.width / options.src.height;
+ if (targetHeight && !targetWidth) {
+ // If we have a height but no width, use height to calculate the width
+ targetWidth = Math.round(targetHeight * aspectRatio);
+ } else if (targetWidth && !targetHeight) {
+ // If we have a width but no height, use width to calculate the height
+ targetHeight = Math.round(targetWidth / aspectRatio);
+ } else if (!targetWidth && !targetHeight) {
+ // If we have neither width or height, use the original image's dimensions
+ targetWidth = options.src.width;
+ targetHeight = options.src.height;
+ }
+ }
+
+ // TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined
+ return {
+ targetWidth: targetWidth!,
+ targetHeight: targetHeight!,
+ };
+}
diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts
new file mode 100644
index 000000000..c6088ad99
--- /dev/null
+++ b/packages/astro/src/assets/services/sharp.ts
@@ -0,0 +1,136 @@
+import type { FitEnum, FormatEnum, SharpOptions } from 'sharp';
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js';
+import {
+ type BaseServiceTransform,
+ type LocalImageService,
+ baseService,
+ parseQuality,
+} from './service.js';
+
+export interface SharpImageServiceConfig {
+ /**
+ * The `limitInputPixels` option passed to Sharp. See https://sharp.pixelplumbing.com/api-constructor for more information
+ */
+ limitInputPixels?: SharpOptions['limitInputPixels'];
+}
+
+let sharp: typeof import('sharp');
+
+const qualityTable: Record<ImageQualityPreset, number> = {
+ low: 25,
+ mid: 50,
+ high: 80,
+ max: 100,
+};
+
+async function loadSharp() {
+ let sharpImport: typeof import('sharp');
+ try {
+ sharpImport = (await import('sharp')).default;
+ } catch {
+ throw new AstroError(AstroErrorData.MissingSharp);
+ }
+
+ // Disable the `sharp` `libvips` cache as it errors when the file is too small and operations are happening too fast (runs into a race condition) https://github.com/lovell/sharp/issues/3935#issuecomment-1881866341
+ sharpImport.cache(false);
+
+ return sharpImport;
+}
+
+const fitMap: Record<ImageFit, keyof FitEnum> = {
+ fill: 'fill',
+ contain: 'inside',
+ cover: 'cover',
+ none: 'outside',
+ 'scale-down': 'inside',
+ outside: 'outside',
+ inside: 'inside',
+};
+
+const sharpService: LocalImageService<SharpImageServiceConfig> = {
+ validateOptions: baseService.validateOptions,
+ getURL: baseService.getURL,
+ parseURL: baseService.parseURL,
+ getHTMLAttributes: baseService.getHTMLAttributes,
+ getSrcSet: baseService.getSrcSet,
+ async transform(inputBuffer, transformOptions, config) {
+ if (!sharp) sharp = await loadSharp();
+ const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
+
+ // Return SVGs as-is
+ // TODO: Sharp has some support for SVGs, we could probably support this once Sharp is the default and only service.
+ if (transform.format === 'svg') return { data: inputBuffer, format: 'svg' };
+
+ const result = sharp(inputBuffer, {
+ failOnError: false,
+ pages: -1,
+ limitInputPixels: config.service.config.limitInputPixels,
+ });
+
+ // always call rotate to adjust for EXIF data orientation
+ result.rotate();
+
+ // If `fit` isn't set then use old behavior:
+ // - Do not use both width and height for resizing, and prioritize width over height
+ // - Allow enlarging images
+
+ const withoutEnlargement = Boolean(transform.fit);
+ if (transform.width && transform.height && transform.fit) {
+ const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside';
+ result.resize({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ fit,
+ position: transform.position,
+ withoutEnlargement,
+ });
+ } else if (transform.height && !transform.width) {
+ result.resize({
+ height: Math.round(transform.height),
+ withoutEnlargement,
+ });
+ } else if (transform.width) {
+ result.resize({
+ width: Math.round(transform.width),
+ withoutEnlargement,
+ });
+ }
+
+ if (transform.format) {
+ let quality: number | string | undefined = undefined;
+ if (transform.quality) {
+ const parsedQuality = parseQuality(transform.quality);
+ if (typeof parsedQuality === 'number') {
+ quality = parsedQuality;
+ } else {
+ quality = transform.quality in qualityTable ? qualityTable[transform.quality] : undefined;
+ }
+ }
+
+ const isGifInput =
+ inputBuffer[0] === 0x47 && // 'G'
+ inputBuffer[1] === 0x49 && // 'I'
+ inputBuffer[2] === 0x46 && // 'F'
+ inputBuffer[3] === 0x38 && // '8'
+ (inputBuffer[4] === 0x39 || inputBuffer[4] === 0x37) && // '9' or '7'
+ inputBuffer[5] === 0x61; // 'a'
+
+ if (transform.format === 'webp' && isGifInput) {
+ // Convert animated GIF to animated WebP with loop=0 (infinite)
+ result.webp({ quality: typeof quality === 'number' ? quality : undefined, loop: 0 });
+ } else {
+ result.toFormat(transform.format as keyof FormatEnum, { quality });
+ }
+ }
+
+ const { data, info } = await result.toBuffer({ resolveWithObject: true });
+
+ return {
+ data: data,
+ format: info.format as ImageOutputFormat,
+ };
+ },
+};
+
+export default sharpService;
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
new file mode 100644
index 000000000..ca1216e71
--- /dev/null
+++ b/packages/astro/src/assets/types.ts
@@ -0,0 +1,286 @@
+import type { OmitPreservingIndexSignature, Simplify, WithRequired } from '../type-utils.js';
+import type { VALID_INPUT_FORMATS, VALID_OUTPUT_FORMATS } from './consts.js';
+import type { ImageService } from './services/service.js';
+
+export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {});
+export type ImageQuality = ImageQualityPreset | number;
+export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number];
+export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {});
+export type ImageLayout = 'constrained' | 'fixed' | 'full-width' | 'none';
+export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
+
+export type AssetsGlobalStaticImagesList = Map<
+ string,
+ {
+ originalSrcPath: string | undefined;
+ transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
+ }
+>;
+
+declare global {
+ // eslint-disable-next-line no-var
+ var astroAsset: {
+ imageService?: ImageService;
+ addStaticImage?:
+ | ((options: ImageTransform, hashProperties: string[], fsPath: string | undefined) => string)
+ | undefined;
+ staticImages?: AssetsGlobalStaticImagesList;
+ referencedImages?: Set<string>;
+ };
+}
+
+const isESMImport = Symbol('#isESM');
+
+export type OmitBrand<T> = Omit<T, typeof isESMImport>;
+
+/**
+ * Type returned by ESM imports of images
+ */
+export type ImageMetadata = {
+ src: string;
+ width: number;
+ height: number;
+ format: ImageInputFormat;
+ orientation?: number;
+ /** @internal */
+ fsPath: string;
+ [isESMImport]?: true;
+};
+
+export function isImageMetadata(src: any): src is ImageMetadata {
+ // For ESM-imported images the fsPath property is set but not enumerable
+ return src.fsPath && !('fsPath' in src);
+}
+
+/**
+ * A yet to be completed with an url `SrcSetValue`. Other hooks will only see a resolved value, where the URL of the image has been added.
+ */
+export type UnresolvedSrcSetValue = {
+ transform: ImageTransform;
+ descriptor?: string;
+ attributes?: Record<string, any>;
+};
+
+export type SrcSetValue = UnresolvedSrcSetValue & {
+ url: string;
+};
+
+/**
+ * A yet to be resolved image transform. Used by `getImage`
+ */
+export type UnresolvedImageTransform = Simplify<
+ OmitPreservingIndexSignature<ImageTransform, 'src'> & {
+ src: ImageMetadata | string | Promise<{ default: ImageMetadata }>;
+ inferSize?: boolean;
+ }
+> & {
+ [isESMImport]?: never;
+};
+
+/**
+ * Options accepted by the image transformation service.
+ */
+export type ImageTransform = {
+ src: ImageMetadata | string;
+ width?: number | undefined;
+ widths?: number[] | undefined;
+ densities?: (number | `${number}x`)[] | undefined;
+ height?: number | undefined;
+ quality?: ImageQuality | undefined;
+ format?: ImageOutputFormat | undefined;
+ fit?: ImageFit | undefined;
+ position?: string | undefined;
+ [key: string]: any;
+};
+
+export interface GetImageResult {
+ rawOptions: ImageTransform;
+ options: ImageTransform;
+ src: string;
+ srcSet: {
+ values: SrcSetValue[];
+ attribute: string;
+ };
+ attributes: Record<string, any>;
+}
+
+type ImageSharedProps<T> = T & {
+ /**
+ * Width of the image, the value of this property will be used to assign the `width` property on the final `img` element.
+ *
+ * This value will additionally be used to resize the image to the desired width, taking into account the original aspect ratio of the image.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} width={300} alt="..." />
+ * ```
+ * **Result**:
+ * ```html
+ * <img src="..." width="300" height="..." alt="..." />
+ * ```
+ */
+ width?: number | `${number}`;
+ /**
+ * Height of the image, the value of this property will be used to assign the `height` property on the final `img` element.
+ *
+ * For local images, if `width` is not present, this value will additionally be used to resize the image to the desired height, taking into account the original aspect ratio of the image.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} height={300} alt="..." />
+ * ```
+ * **Result**:
+ * ```html
+ * <img src="..." height="300" width="..." alt="..." />
+ * ```
+ */
+ height?: number | `${number}`;
+ /**
+ * Desired output format for the image. Defaults to `webp`.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} format="avif" alt="..." />
+ * ```
+ */
+ format?: ImageOutputFormat;
+ /**
+ * Desired quality for the image. Value can either be a preset such as `low` or `high`, or a numeric value from 0 to 100.
+ *
+ * The perceptual quality of the output image is service-specific.
+ * For instance, a certain service might decide that `high` results in a very beautiful image, but another could choose for it to be at best passable.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} quality='high' alt="..." />
+ * <Image src={...} quality={300} alt="..." />
+ * ```
+ */
+ quality?: ImageQuality;
+} & (
+ | {
+ /**
+ * The layout type for responsive images. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Allowed values are `constrained`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
+ *
+ * - `constrained` - The image will scale to fit the container, maintaining its aspect ratio, but will not exceed the specified dimensions.
+ * - `fixed` - The image will maintain its original dimensions.
+ * - `full-width` - The image will scale to fit the container, maintaining its aspect ratio, even if that means the image will exceed its original dimensions.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} layout="constrained" alt="..." />
+ * ```
+ */
+
+ layout?: ImageLayout;
+
+ /**
+ * Defines how the image should be cropped if the aspect ratio is changed. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} fit="contain" alt="..." />
+ * ```
+ */
+
+ fit?: ImageFit;
+
+ /**
+ * Defines the position of the image when cropping. Requires the `experimental.responsiveImages` flag to be enabled in the Astro config.
+ *
+ * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service.
+ *
+ * **Example**:
+ * ```astro
+ * <Image src={...} position="center top" alt="..." />
+ * ```
+ */
+
+ position?: string;
+ /**
+ * If true, the image will be loaded with a higher priority. This can be useful for images that are visible above the fold. There should usually be only one image with `priority` set to `true` per page.
+ * All other images will be lazy-loaded according to when they are in the viewport.
+ * **Example**:
+ * ```astro
+ * <Image src={...} priority alt="..." />
+ * ```
+ */
+ priority?: boolean;
+
+ /**
+ * A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
+ *
+ * This attribute is incompatible with `densities`.
+ */
+ widths?: number[];
+ densities?: never;
+ }
+ | {
+ /**
+ * A list of pixel densities to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element.
+ *
+ * This attribute is incompatible with `widths`.
+ */
+ densities?: (number | `${number}x`)[];
+ widths?: never;
+ layout?: never;
+ fit?: never;
+ position?: never;
+ }
+ );
+
+export type LocalImageProps<T> = ImageSharedProps<T> & {
+ /**
+ * A reference to a local image imported through an ESM import.
+ *
+ * **Example**:
+ * ```js
+ * import myImage from "../assets/my_image.png";
+ * ```
+ * And then refer to the image, like so:
+ * ```astro
+ * <Image src={myImage} alt="..."></Image>
+ * ```
+ */
+ src: ImageMetadata | Promise<{ default: ImageMetadata }>;
+};
+
+export type RemoteImageProps<T> =
+ | (ImageSharedProps<T> & {
+ /**
+ * URL of a remote image. Can start with a protocol (ex: `https://`) or alternatively `/`, or `Astro.url`, for images in the `public` folder
+ *
+ * Remote images are not optimized, and require both `width` and `height` to be set.
+ *
+ * **Example**:
+ * ```
+ * <Image src="https://example.com/image.png" width={450} height={300} alt="..." />
+ * ```
+ */
+ src: string;
+ /**
+ * When inferSize is true width and height are not required
+ */
+ inferSize: true;
+ })
+ | (WithRequired<ImageSharedProps<T>, 'width' | 'height'> & {
+ /**
+ * URL of a remote image. Can start with a protocol (ex: `https://`) or alternatively `/`, or `Astro.url`, for images in the `public` folder
+ *
+ * Remote images are not optimized, and require both `width` and `height` to be set.
+ *
+ * **Example**:
+ * ```
+ * <Image src="https://example.com/image.png" width={450} height={300} alt="..." />
+ * ```
+ */
+ src: string;
+ /**
+ * When inferSize is false or undefined width and height are required
+ */
+ inferSize?: false | undefined;
+ });
diff --git a/packages/astro/src/assets/utils/etag.ts b/packages/astro/src/assets/utils/etag.ts
new file mode 100644
index 000000000..d1650b8c0
--- /dev/null
+++ b/packages/astro/src/assets/utils/etag.ts
@@ -0,0 +1,45 @@
+/**
+ * FNV-1a Hash implementation
+ * @author Travis Webb (tjwebb) <me@traviswebb.com>
+ *
+ * Ported from https://github.com/tjwebb/fnv-plus/blob/master/index.js
+ * License https://github.com/tjwebb/fnv-plus#license
+ *
+ * Simplified, optimized and add modified for 52 bit, which provides a larger hash space
+ * and still making use of Javascript's 53-bit integer space.
+ */
+const fnv1a52 = (str: string) => {
+ const len = str.length;
+ let i = 0,
+ t0 = 0,
+ v0 = 0x2325,
+ t1 = 0,
+ v1 = 0x8422,
+ t2 = 0,
+ v2 = 0x9ce4,
+ t3 = 0,
+ v3 = 0xcbf2;
+
+ while (i < len) {
+ v0 ^= str.charCodeAt(i++);
+ t0 = v0 * 435;
+ t1 = v1 * 435;
+ t2 = v2 * 435;
+ t3 = v3 * 435;
+ t2 += v0 << 8;
+ t3 += v1 << 8;
+ t1 += t0 >>> 16;
+ v0 = t0 & 65535;
+ t2 += t1 >>> 16;
+ v1 = t1 & 65535;
+ v3 = (t3 + (t2 >>> 16)) & 65535;
+ v2 = t2 & 65535;
+ }
+
+ return (v3 & 15) * 281474976710656 + v2 * 4294967296 + v1 * 65536 + (v0 ^ (v3 >> 4));
+};
+
+export const etag = (payload: string, weak = false) => {
+ const prefix = weak ? 'W/"' : '"';
+ return prefix + fnv1a52(payload).toString(36) + payload.length.toString(36) + '"';
+};
diff --git a/packages/astro/src/assets/utils/getAssetsPrefix.ts b/packages/astro/src/assets/utils/getAssetsPrefix.ts
new file mode 100644
index 000000000..1a8947b54
--- /dev/null
+++ b/packages/astro/src/assets/utils/getAssetsPrefix.ts
@@ -0,0 +1,12 @@
+import type { AssetsPrefix } from '../../core/app/types.js';
+
+export function getAssetsPrefix(fileExtension: string, assetsPrefix?: AssetsPrefix): string {
+ if (!assetsPrefix) return '';
+ if (typeof assetsPrefix === 'string') return assetsPrefix;
+ // we assume the file extension has a leading '.' and we remove it
+ const dotLessFileExtension = fileExtension.slice(1);
+ if (assetsPrefix[dotLessFileExtension]) {
+ return assetsPrefix[dotLessFileExtension];
+ }
+ return assetsPrefix.fallback;
+}
diff --git a/packages/astro/src/assets/utils/imageAttributes.ts b/packages/astro/src/assets/utils/imageAttributes.ts
new file mode 100644
index 000000000..e7d1b6949
--- /dev/null
+++ b/packages/astro/src/assets/utils/imageAttributes.ts
@@ -0,0 +1,20 @@
+import { toStyleString } from '../../runtime/server/render/util.js';
+
+export const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
+
+export function addCSSVarsToStyle(
+ vars: Record<string, string | false | undefined>,
+ styles?: string | Record<string, any>,
+) {
+ const cssVars = Object.entries(vars)
+ .filter(([_, value]) => value !== undefined && value !== false)
+ .map(([key, value]) => `--${key}: ${value};`)
+ .join(' ');
+
+ if (!styles) {
+ return cssVars;
+ }
+ const style = typeof styles === 'string' ? styles : toStyleString(styles);
+
+ return `${cssVars} ${style}`;
+}
diff --git a/packages/astro/src/assets/utils/imageKind.ts b/packages/astro/src/assets/utils/imageKind.ts
new file mode 100644
index 000000000..f34f45749
--- /dev/null
+++ b/packages/astro/src/assets/utils/imageKind.ts
@@ -0,0 +1,37 @@
+import type { ImageMetadata, UnresolvedImageTransform } from '../types.js';
+
+/**
+ * Determines if the given source is an ECMAScript Module (ESM) imported image.
+ *
+ * @param {ImageMetadata | string} src - The source to check. It can be an `ImageMetadata` object or a string.
+ * @return {boolean} Returns `true` if the source is an `ImageMetadata` object, otherwise `false`.
+ */
+export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
+ return typeof src === 'object' || (typeof src === 'function' && 'src' in src);
+}
+
+/**
+ * Determines if the provided source is a remote image URL in the form of a string.
+ *
+ * @param {ImageMetadata | string} src - The source to check, which can either be an `ImageMetadata` object or a string.
+ * @return {boolean} Returns `true` if the source is a string, otherwise `false`.
+ */
+export function isRemoteImage(src: ImageMetadata | string): src is string {
+ return typeof src === 'string';
+}
+
+/**
+ * Resolves the source of an image transformation by handling asynchronous or synchronous inputs.
+ *
+ * @param {UnresolvedImageTransform['src']} src - The source of the image transformation.
+ * @return {Promise<string | ImageMetadata>} A promise that resolves to the image source. It returns either the default export of the resolved source or the resolved source itself if the default export doesn't exist.
+ */
+export async function resolveSrc(
+ src: UnresolvedImageTransform['src'],
+): Promise<string | ImageMetadata> {
+ if (typeof src === 'object' && 'then' in src) {
+ const resource = await src;
+ return resource.default ?? resource;
+ }
+ return src;
+}
diff --git a/packages/astro/src/assets/utils/index.ts b/packages/astro/src/assets/utils/index.ts
new file mode 100644
index 000000000..8f050a622
--- /dev/null
+++ b/packages/astro/src/assets/utils/index.ts
@@ -0,0 +1,28 @@
+/**
+ * NOTE: this is a public module exposed to the user, so all functions exposed
+ * here must be documented via JsDoc and in the docs website.
+ *
+ * If some functions don't need to be exposed, just import the file that contains the functions.
+ */
+
+export {
+ /**
+ * @deprecated
+ */
+ emitESMImage,
+ emitImageMetadata,
+} from './node/emitAsset.js';
+export { isESMImportedImage, isRemoteImage } from './imageKind.js';
+export { imageMetadata } from './metadata.js';
+export { getOrigQueryParams } from './queryParams.js';
+export { hashTransform, propsToFilename } from './transformToPath.js';
+export { inferRemoteSize } from './remoteProbe.js';
+export {
+ isRemoteAllowed,
+ matchHostname,
+ matchPathname,
+ matchPattern,
+ matchPort,
+ matchProtocol,
+ type RemotePattern,
+} from './remotePattern.js';
diff --git a/packages/astro/src/assets/utils/metadata.ts b/packages/astro/src/assets/utils/metadata.ts
new file mode 100644
index 000000000..5ac51fe29
--- /dev/null
+++ b/packages/astro/src/assets/utils/metadata.ts
@@ -0,0 +1,42 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageInputFormat, ImageMetadata } from '../types.js';
+import { lookup as probe } from '../utils/vendor/image-size/lookup.js';
+
+/**
+ * Extracts image metadata such as dimensions, format, and orientation from the provided image data.
+ *
+ * @param {Uint8Array} data - The binary data of the image.
+ * @param {string} [src] - The source path or URL of the image, used for error messages. Optional.
+ * @return {Promise<Omit<ImageMetadata, 'src' | 'fsPath'>>} A promise that resolves with the extracted metadata, excluding `src` and `fsPath`.
+ * @throws {AstroError} Throws an error if the image metadata cannot be extracted.
+ */
+export async function imageMetadata(
+ data: Uint8Array,
+ src?: string,
+): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
+ let result;
+ try {
+ result = probe(data);
+ } catch {
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(src),
+ });
+ }
+ if (!result.height || !result.width || !result.type) {
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(src),
+ });
+ }
+
+ const { width, height, type, orientation } = result;
+ const isPortrait = (orientation || 0) >= 5;
+
+ return {
+ width: isPortrait ? height : width,
+ height: isPortrait ? width : height,
+ format: type as ImageInputFormat,
+ orientation,
+ };
+}
diff --git a/packages/astro/src/assets/utils/node/emitAsset.ts b/packages/astro/src/assets/utils/node/emitAsset.ts
new file mode 100644
index 000000000..e9662701d
--- /dev/null
+++ b/packages/astro/src/assets/utils/node/emitAsset.ts
@@ -0,0 +1,162 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import type * as vite from 'vite';
+import { prependForwardSlash, slash } from '../../../core/path.js';
+import type { ImageMetadata } from '../../types.js';
+import { imageMetadata } from '../metadata.js';
+
+type FileEmitter = vite.Rollup.EmitFile;
+type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
+
+/**
+ * Processes an image file and emits its metadata and optionally its contents. This function supports both build and development modes.
+ *
+ * @param {string | undefined} id - The identifier or path of the image file to process. If undefined, the function returns immediately.
+ * @param {boolean} _watchMode - **Deprecated**: Indicates if the method is operating in watch mode. This parameter will be removed or updated in the future.
+ * @param {boolean} _experimentalSvgEnabled - **Deprecated**: A flag to enable experimental handling of SVG files. Embeds SVG file data if set to true.
+ * @param {FileEmitter | undefined} [fileEmitter] - Function for emitting files during the build process. May throw in certain scenarios.
+ * @return {Promise<ImageMetadataWithContents | undefined>} Resolves to metadata with optional image contents or `undefined` if processing fails.
+ */
+// We want to internally use this function until we fix the memory in the SVG features
+export async function emitESMImage(
+ id: string | undefined,
+ /** @deprecated */
+ _watchMode: boolean,
+ // FIX: in Astro 6, this function should not be passed in dev mode at all.
+ // Or rethink the API so that a function that throws isn't passed through.
+ /** @deprecated */
+ _experimentalSvgEnabled: boolean,
+ fileEmitter?: FileEmitter,
+): Promise<ImageMetadataWithContents | undefined> {
+ if (!id) {
+ return undefined;
+ }
+
+ const url = pathToFileURL(id);
+ let fileData: Buffer;
+ try {
+ fileData = await fs.readFile(url);
+ } catch {
+ return undefined;
+ }
+
+ const fileMetadata = await imageMetadata(fileData, id);
+
+ const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = {
+ src: '',
+ ...fileMetadata,
+ };
+
+ // Private for now, we generally don't want users to rely on filesystem paths, but we need it so that we can maybe remove the original asset from the build if it's unused.
+ Object.defineProperty(emittedImage, 'fsPath', {
+ enumerable: false,
+ writable: false,
+ value: id,
+ });
+
+ // Build
+ let isBuild = typeof fileEmitter === 'function';
+ if (isBuild) {
+ const pathname = decodeURI(url.pathname);
+ const filename = path.basename(pathname, path.extname(pathname) + `.${fileMetadata.format}`);
+
+ try {
+ // fileEmitter throws in dev
+ const handle = fileEmitter!({
+ name: filename,
+ source: await fs.readFile(url),
+ type: 'asset',
+ });
+
+ emittedImage.src = `__ASTRO_ASSET_IMAGE__${handle}__`;
+ } catch {
+ isBuild = false;
+ }
+ }
+
+ if (!isBuild) {
+ // Pass the original file information through query params so we don't have to load the file twice
+ url.searchParams.append('origWidth', fileMetadata.width.toString());
+ url.searchParams.append('origHeight', fileMetadata.height.toString());
+ url.searchParams.append('origFormat', fileMetadata.format);
+
+ emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
+ }
+
+ return emittedImage as ImageMetadataWithContents;
+}
+
+/**
+ * Processes an image file and emits its metadata and optionally its contents. This function supports both build and development modes.
+ *
+ * @param {string | undefined} id - The identifier or path of the image file to process. If undefined, the function returns immediately.
+ * @param {FileEmitter | undefined} [fileEmitter] - Function for emitting files during the build process. May throw in certain scenarios.
+ * @return {Promise<ImageMetadataWithContents | undefined>} Resolves to metadata with optional image contents or `undefined` if processing fails.
+ */
+export async function emitImageMetadata(
+ id: string | undefined,
+ fileEmitter?: FileEmitter,
+): Promise<ImageMetadataWithContents | undefined> {
+ if (!id) {
+ return undefined;
+ }
+
+ const url = pathToFileURL(id);
+ let fileData: Buffer;
+ try {
+ fileData = await fs.readFile(url);
+ } catch {
+ return undefined;
+ }
+
+ const fileMetadata = await imageMetadata(fileData, id);
+
+ const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = {
+ src: '',
+ ...fileMetadata,
+ };
+
+ // Private for now, we generally don't want users to rely on filesystem paths, but we need it so that we can maybe remove the original asset from the build if it's unused.
+ Object.defineProperty(emittedImage, 'fsPath', {
+ enumerable: false,
+ writable: false,
+ value: id,
+ });
+
+ // Build
+ let isBuild = typeof fileEmitter === 'function';
+ if (isBuild) {
+ const pathname = decodeURI(url.pathname);
+ const filename = path.basename(pathname, path.extname(pathname) + `.${fileMetadata.format}`);
+
+ try {
+ // fileEmitter throws in dev
+ const handle = fileEmitter!({
+ name: filename,
+ source: await fs.readFile(url),
+ type: 'asset',
+ });
+
+ emittedImage.src = `__ASTRO_ASSET_IMAGE__${handle}__`;
+ } catch {
+ isBuild = false;
+ }
+ }
+
+ if (!isBuild) {
+ // Pass the original file information through query params so we don't have to load the file twice
+ url.searchParams.append('origWidth', fileMetadata.width.toString());
+ url.searchParams.append('origHeight', fileMetadata.height.toString());
+ url.searchParams.append('origFormat', fileMetadata.format);
+
+ emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
+ }
+
+ return emittedImage as ImageMetadataWithContents;
+}
+
+function fileURLToNormalizedPath(filePath: URL): string {
+ // Uses `slash` instead of Vite's `normalizePath` to avoid CJS bundling issues.
+ return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/');
+}
diff --git a/packages/astro/src/assets/utils/proxy.ts b/packages/astro/src/assets/utils/proxy.ts
new file mode 100644
index 000000000..975e8e0f3
--- /dev/null
+++ b/packages/astro/src/assets/utils/proxy.ts
@@ -0,0 +1,23 @@
+import type { ImageMetadata } from '../types.js';
+
+export function getProxyCode(options: ImageMetadata, isSSR: boolean): string {
+ const stringifiedFSPath = JSON.stringify(options.fsPath);
+ return `
+ new Proxy(${JSON.stringify(options)}, {
+ get(target, name, receiver) {
+ if (name === 'clone') {
+ return structuredClone(target);
+ }
+ if (name === 'fsPath') {
+ return ${stringifiedFSPath};
+ }
+ ${
+ !isSSR
+ ? `if (target[name] !== undefined && globalThis.astroAsset) globalThis.astroAsset?.referencedImages.add(${stringifiedFSPath});`
+ : ''
+ }
+ return target[name];
+ }
+ })
+ `;
+}
diff --git a/packages/astro/src/assets/utils/queryParams.ts b/packages/astro/src/assets/utils/queryParams.ts
new file mode 100644
index 000000000..f2c0251e4
--- /dev/null
+++ b/packages/astro/src/assets/utils/queryParams.ts
@@ -0,0 +1,28 @@
+import type { ImageInputFormat, ImageMetadata } from '../types.js';
+
+/**
+ * Extracts the original image query parameters (width, height, format) from the given `URLSearchParams` object
+ * and returns them as an object. If any of the required parameters are missing or invalid, the function returns undefined.
+ *
+ * The `width` and `height` are parsed to integer values.
+ *
+ * @param {URLSearchParams} params - The `URLSearchParams` object containing the query parameters.
+ * @return {Pick<ImageMetadata, 'width' | 'height' | 'format'> | undefined} An object with the original image parameters (width, height, format) or undefined if any parameter is missing.
+ */
+export function getOrigQueryParams(
+ params: URLSearchParams,
+): Pick<ImageMetadata, 'width' | 'height' | 'format'> | undefined {
+ const width = params.get('origWidth');
+ const height = params.get('origHeight');
+ const format = params.get('origFormat');
+
+ if (!width || !height || !format) {
+ return undefined;
+ }
+
+ return {
+ width: parseInt(width),
+ height: parseInt(height),
+ format: format as ImageInputFormat,
+ };
+}
diff --git a/packages/astro/src/assets/utils/remotePattern.ts b/packages/astro/src/assets/utils/remotePattern.ts
new file mode 100644
index 000000000..f790f0c36
--- /dev/null
+++ b/packages/astro/src/assets/utils/remotePattern.ts
@@ -0,0 +1,13 @@
+import {
+ type RemotePattern,
+ isRemoteAllowed,
+ matchHostname,
+ matchPathname,
+ matchPattern,
+ matchPort,
+ matchProtocol,
+} from '@astrojs/internal-helpers/remote';
+
+export { isRemoteAllowed, matchHostname, matchPort, matchPathname, matchProtocol, matchPattern };
+
+export type { RemotePattern };
diff --git a/packages/astro/src/assets/utils/remoteProbe.ts b/packages/astro/src/assets/utils/remoteProbe.ts
new file mode 100644
index 000000000..2b7bba38b
--- /dev/null
+++ b/packages/astro/src/assets/utils/remoteProbe.ts
@@ -0,0 +1,63 @@
+import { AstroError, AstroErrorData } from '../../core/errors/index.js';
+import type { ImageMetadata } from '../types.js';
+import { imageMetadata } from './metadata.js';
+
+/**
+ * Infers the dimensions of a remote image by streaming its data and analyzing it progressively until sufficient metadata is available.
+ *
+ * @param {string} url - The URL of the remote image from which to infer size metadata.
+ * @return {Promise<Omit<ImageMetadata, 'src' | 'fsPath'>>} Returns a promise that resolves to an object containing the image dimensions metadata excluding `src` and `fsPath`.
+ * @throws {AstroError} Thrown when the fetching fails or metadata cannot be extracted.
+ */
+export async function inferRemoteSize(url: string): Promise<Omit<ImageMetadata, 'src' | 'fsPath'>> {
+ // Start fetching the image
+ const response = await fetch(url);
+ if (!response.body || !response.ok) {
+ throw new AstroError({
+ ...AstroErrorData.FailedToFetchRemoteImageDimensions,
+ message: AstroErrorData.FailedToFetchRemoteImageDimensions.message(url),
+ });
+ }
+
+ const reader = response.body.getReader();
+
+ let done: boolean | undefined, value: Uint8Array;
+ let accumulatedChunks = new Uint8Array();
+
+ // Process the stream chunk by chunk
+ while (!done) {
+ const readResult = await reader.read();
+ done = readResult.done;
+
+ if (done) break;
+
+ if (readResult.value) {
+ value = readResult.value;
+
+ // Accumulate chunks
+ let tmp = new Uint8Array(accumulatedChunks.length + value.length);
+ tmp.set(accumulatedChunks, 0);
+ tmp.set(value, accumulatedChunks.length);
+ accumulatedChunks = tmp;
+
+ try {
+ // Attempt to determine the size with each new chunk
+ const dimensions = await imageMetadata(accumulatedChunks, url);
+
+ if (dimensions) {
+ await reader.cancel(); // stop stream as we have size now
+
+ return dimensions;
+ }
+ } catch {
+ // This catch block is specifically for `imageMetadata` errors
+ // which might occur if the accumulated data isn't yet sufficient.
+ }
+ }
+ }
+
+ throw new AstroError({
+ ...AstroErrorData.NoImageMetadata,
+ message: AstroErrorData.NoImageMetadata.message(url),
+ });
+}
diff --git a/packages/astro/src/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts
new file mode 100644
index 000000000..93e312487
--- /dev/null
+++ b/packages/astro/src/assets/utils/resolveImports.ts
@@ -0,0 +1,44 @@
+import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path';
+import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js';
+import { shorthash } from '../../runtime/server/shorthash.js';
+import { VALID_INPUT_FORMATS } from '../consts.js';
+
+/**
+ * Resolves an image src from a content file (such as markdown) to a module ID or import that can be resolved by Vite.
+ *
+ * @param imageSrc The src attribute of an image tag
+ * @param filePath The path to the file that contains the imagem relative to the site root
+ * @returns A module id of the image that can be rsolved by Vite, or undefined if it is not a local image
+ */
+export function imageSrcToImportId(imageSrc: string, filePath?: string): string | undefined {
+ // If the import is coming from the data store it will have a special prefix to identify it
+ // as an image import. We remove this prefix so that we can resolve the image correctly.
+ imageSrc = removeBase(imageSrc, IMAGE_IMPORT_PREFIX);
+
+ // We only care about local imports
+ if (isRemotePath(imageSrc)) {
+ return;
+ }
+ // We only care about images
+ const ext = imageSrc.split('.').at(-1)?.toLowerCase() as
+ | (typeof VALID_INPUT_FORMATS)[number]
+ | undefined;
+ if (!ext || !VALID_INPUT_FORMATS.includes(ext)) {
+ return;
+ }
+
+ // The import paths are relative to the content (md) file, but when it's actually resolved it will
+ // be in a single assets file, so relative paths will no longer work. To deal with this we use
+ // a query parameter to store the original path to the file and append a query param flag.
+ // This allows our Vite plugin to intercept the import and resolve the path relative to the
+ // importer and get the correct full path for the imported image.
+
+ const params = new URLSearchParams(CONTENT_IMAGE_FLAG);
+ if (filePath) {
+ params.set('importer', filePath);
+ }
+ return `${imageSrc}?${params.toString()}`;
+}
+
+export const importIdToSymbolName = (importId: string) =>
+ `__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`;
diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts
new file mode 100644
index 000000000..c272f345c
--- /dev/null
+++ b/packages/astro/src/assets/utils/svg.ts
@@ -0,0 +1,31 @@
+import { parse, renderSync } from 'ultrahtml';
+import type { SvgComponentProps } from '../runtime.js';
+import { dropAttributes } from '../runtime.js';
+import type { ImageMetadata } from '../types.js';
+
+function parseSvg(contents: string) {
+ const root = parse(contents);
+ const svgNode = root.children.find(
+ ({ name, type }: { name: string; type: number }) => type === 1 /* Element */ && name === 'svg',
+ );
+ if (!svgNode) {
+ throw new Error('SVG file does not contain an <svg> element');
+ }
+ const { attributes, children } = svgNode;
+ const body = renderSync({ ...root, children });
+
+ return { attributes, body };
+}
+
+export function makeSvgComponent(meta: ImageMetadata, contents: Buffer | string) {
+ const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
+ const { attributes, body: children } = parseSvg(file);
+ const props: SvgComponentProps = {
+ meta,
+ attributes: dropAttributes(attributes),
+ children,
+ };
+
+ return `import { createSvgComponent } from 'astro/assets/runtime';
+export default createSvgComponent(${JSON.stringify(props)})`;
+}
diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts
new file mode 100644
index 000000000..dbde75876
--- /dev/null
+++ b/packages/astro/src/assets/utils/transformToPath.ts
@@ -0,0 +1,69 @@
+import { basename, dirname, extname } from 'node:path';
+import { deterministicString } from 'deterministic-object-hash';
+import { removeQueryString } from '../../core/path.js';
+import { shorthash } from '../../runtime/server/shorthash.js';
+import type { ImageTransform } from '../types.js';
+import { isESMImportedImage } from './imageKind.js';
+
+/**
+ * Converts a file path and transformation properties of the transformation image service, into a formatted filename.
+ *
+ * The formatted filename follows this structure:
+ *
+ * `<prefixDirname>/<baseFilename>_<hash><outputExtension>`
+ *
+ * - `prefixDirname`: If the image is an ESM imported image, this is the directory name of the original file path; otherwise, it will be an empty string.
+ * - `baseFilename`: The base name of the file or a hashed short name if the file is a `data:` URI.
+ * - `hash`: A unique hash string generated to distinguish the transformed file.
+ * - `outputExtension`: The desired output file extension derived from the `transform.format` or the original file extension.
+ *
+ * ## Example
+ * - Input: `filePath = '/images/photo.jpg'`, `transform = { format: 'png', src: '/images/photo.jpg' }`, `hash = 'abcd1234'`.
+ * - Output: `/images/photo_abcd1234.png`
+ *
+ * @param {string} filePath - The original file path or data URI of the source image.
+ * @param {ImageTransform} transform - An object representing the transformation properties, including format and source.
+ * @param {string} hash - A unique hash used to differentiate the transformed file.
+ * @return {string} The generated filename based on the provided input, transformations, and hash.
+ */
+
+export function propsToFilename(filePath: string, transform: ImageTransform, hash: string): string {
+ let filename = decodeURIComponent(removeQueryString(filePath));
+ const ext = extname(filename);
+ if (filePath.startsWith('data:')) {
+ filename = shorthash(filePath);
+ } else {
+ filename = basename(filename, ext);
+ }
+ const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : '';
+
+ let outputExt = transform.format ? `.${transform.format}` : ext;
+ return decodeURIComponent(`${prefixDirname}/${filename}_${hash}${outputExt}`);
+}
+
+/**
+ * Transforms the provided `transform` object into a hash string based on selected properties
+ * and the specified `imageService`.
+ *
+ * @param {ImageTransform} transform - The transform object containing various image transformation properties.
+ * @param {string} imageService - The name of the image service related to the transform.
+ * @param {string[]} propertiesToHash - An array of property names from the `transform` object that should be used to generate the hash.
+ * @return {string} A hashed string created from the specified properties of the `transform` object and the image service.
+ */
+export function hashTransform(
+ transform: ImageTransform,
+ imageService: string,
+ propertiesToHash: string[],
+): string {
+ // Extract the fields we want to hash
+ const hashFields = propertiesToHash.reduce(
+ (acc, prop) => {
+ // It's possible for `transform[prop]` here to be undefined, or null, but that's fine because it's still consistent
+ // between different transforms. (ex: every transform without a height will explicitly have a `height: undefined` property)
+ acc[prop] = transform[prop];
+ return acc;
+ },
+ { imageService } as Record<string, unknown>,
+ );
+ return shorthash(deterministicString(hashFields));
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/LICENSE b/packages/astro/src/assets/utils/vendor/image-size/LICENSE
new file mode 100644
index 000000000..8bdffcff7
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/LICENSE
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright © 2013-Present Aditya Yadav, http://netroy.in
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/astro/src/assets/utils/vendor/image-size/README.md b/packages/astro/src/assets/utils/vendor/image-size/README.md
new file mode 100644
index 000000000..5c800006a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/README.md
@@ -0,0 +1,3 @@
+This code comes from https://github.com/image-size/image-size/pull/370, and is slightly modified (all import statements have file extensions added to them).
+
+The `fromFile` functionality has also been removed, as it was not being used.
diff --git a/packages/astro/src/assets/utils/vendor/image-size/detector.ts b/packages/astro/src/assets/utils/vendor/image-size/detector.ts
new file mode 100644
index 000000000..367405281
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/detector.ts
@@ -0,0 +1,25 @@
+import type { imageType } from './types/index.js'
+import { typeHandlers, types } from './types/index.js'
+
+// This map helps avoid validating for every single image type
+const firstBytes = new Map<number, imageType>([
+ [0x38, 'psd'],
+ [0x42, 'bmp'],
+ [0x44, 'dds'],
+ [0x47, 'gif'],
+ [0x49, 'tiff'],
+ [0x4d, 'tiff'],
+ [0x52, 'webp'],
+ [0x69, 'icns'],
+ [0x89, 'png'],
+ [0xff, 'jpg'],
+])
+
+export function detector(input: Uint8Array): imageType | undefined {
+ const byte = input[0]
+ const type = firstBytes.get(byte)
+ if (type && typeHandlers.get(type)!.validate(input)) {
+ return type
+ }
+ return types.find((fileType) => typeHandlers.get(fileType)!.validate(input))
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/lookup.ts b/packages/astro/src/assets/utils/vendor/image-size/lookup.ts
new file mode 100644
index 000000000..d3fd3fe19
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/lookup.ts
@@ -0,0 +1,26 @@
+import { typeHandlers } from './types/index.js'
+import { detector } from './detector.js'
+import type { ISizeCalculationResult } from './types/interface.ts'
+
+/**
+ * Return size information based on an Uint8Array
+ *
+ * @param {Uint8Array} input
+ * @returns {ISizeCalculationResult}
+ */
+export function lookup(input: Uint8Array): ISizeCalculationResult {
+ // detect the file type... don't rely on the extension
+ const type = detector(input)
+
+ if (typeof type !== 'undefined') {
+ // find an appropriate handler for this file type
+ const size = typeHandlers.get(type)!.calculate(input)
+ if (size !== undefined) {
+ size.type = size.type ?? type
+ return size
+ }
+ }
+
+ // throw up, if we don't understand the file
+ throw new TypeError('unsupported file type: ' + type)
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts b/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts
new file mode 100644
index 000000000..45c729c6a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/bmp.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readInt32LE, readUInt32LE } from './utils.js'
+
+export const BMP: IImage = {
+ validate: (input) => toUTF8String(input, 0, 2) === 'BM',
+
+ calculate: (input) => ({
+ height: Math.abs(readInt32LE(input, 22)),
+ width: readUInt32LE(input, 18),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts b/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts
new file mode 100644
index 000000000..19e00a836
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/cur.ts
@@ -0,0 +1,17 @@
+import type { IImage } from './interface.ts'
+import { ICO } from './ico.js'
+import { readUInt16LE } from './utils.js'
+
+const TYPE_CURSOR = 2
+export const CUR: IImage = {
+ validate(input) {
+ const reserved = readUInt16LE(input, 0)
+ const imageCount = readUInt16LE(input, 4)
+ if (reserved !== 0 || imageCount === 0) return false
+
+ const imageType = readUInt16LE(input, 2)
+ return imageType === TYPE_CURSOR
+ },
+
+ calculate: (input) => ICO.calculate(input),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts b/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts
new file mode 100644
index 000000000..b5ab22649
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/dds.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { readUInt32LE } from './utils.js'
+
+export const DDS: IImage = {
+ validate: (input) => readUInt32LE(input, 0) === 0x20534444,
+
+ calculate: (input) => ({
+ height: readUInt32LE(input, 12),
+ width: readUInt32LE(input, 16),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts b/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts
new file mode 100644
index 000000000..7ef2b7c7e
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/gif.ts
@@ -0,0 +1,12 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt16LE } from './utils.js'
+
+const gifRegexp = /^GIF8[79]a/
+export const GIF: IImage = {
+ validate: (input) => gifRegexp.test(toUTF8String(input, 0, 6)),
+
+ calculate: (input) => ({
+ height: readUInt16LE(input, 8),
+ width: readUInt16LE(input, 6),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts b/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts
new file mode 100644
index 000000000..218b98fee
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/heif.ts
@@ -0,0 +1,55 @@
+import type { IImage } from './interface.ts'
+import { findBox, readUInt32BE, toUTF8String } from './utils.js'
+
+const brandMap = {
+ avif: 'avif',
+ mif1: 'heif',
+ msf1: 'heif', // hief-sequence
+ heic: 'heic',
+ heix: 'heic',
+ hevc: 'heic', // heic-sequence
+ hevx: 'heic', // heic-sequence
+}
+
+function detectBrands(buffer: Uint8Array, start: number, end: number) {
+ let brandsDetected = {} as Record<keyof typeof brandMap, 1>;
+ for (let i = start; i <= end; i += 4) {
+ const brand = toUTF8String(buffer, i, i + 4);
+ if (brand in brandMap) {
+ brandsDetected[brand as keyof typeof brandMap] = 1;
+ }
+ }
+
+ // Determine the most relevant type based on detected brands
+ if ('avif' in brandsDetected) {
+ return 'avif';
+ } else if ('heic' in brandsDetected || 'heix' in brandsDetected || 'hevc' in brandsDetected || 'hevx' in brandsDetected) {
+ return 'heic';
+ } else if ('mif1' in brandsDetected || 'msf1' in brandsDetected) {
+ return 'heif';
+ }
+}
+
+export const HEIF: IImage = {
+ validate(buffer) {
+ const ftype = toUTF8String(buffer, 4, 8)
+ const brand = toUTF8String(buffer, 8, 12)
+ return 'ftyp' === ftype && brand in brandMap
+ },
+
+ calculate(buffer) {
+ // Based on https://nokiatech.github.io/heif/technical.html
+ const metaBox = findBox(buffer, 'meta', 0)
+ const iprpBox = metaBox && findBox(buffer, 'iprp', metaBox.offset + 12)
+ const ipcoBox = iprpBox && findBox(buffer, 'ipco', iprpBox.offset + 8)
+ const ispeBox = ipcoBox && findBox(buffer, 'ispe', ipcoBox.offset + 8)
+ if (ispeBox) {
+ return {
+ height: readUInt32BE(buffer, ispeBox.offset + 16),
+ width: readUInt32BE(buffer, ispeBox.offset + 12),
+ type: detectBrands(buffer, 8, metaBox.offset),
+ }
+ }
+ throw new TypeError('Invalid HEIF, no size found')
+ }
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts b/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts
new file mode 100644
index 000000000..d5bd12fad
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/icns.ts
@@ -0,0 +1,113 @@
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+/**
+ * ICNS Header
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 4 | Magic literal, must be "icns" (0x69, 0x63, 0x6e, 0x73) |
+ * | 4 | 4 | Length of file, in bytes, msb first. |
+ *
+ */
+const SIZE_HEADER = 4 + 4 // 8
+const FILE_LENGTH_OFFSET = 4 // MSB => BIG ENDIAN
+
+/**
+ * Image Entry
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 4 | Icon type, see OSType below. |
+ * | 4 | 4 | Length of data, in bytes (including type and length), msb first. |
+ * | 8 | n | Icon data |
+ */
+const ENTRY_LENGTH_OFFSET = 4 // MSB => BIG ENDIAN
+
+const ICON_TYPE_SIZE: { [key: string]: number } = {
+ ICON: 32,
+ 'ICN#': 32,
+ // m => 16 x 16
+ 'icm#': 16,
+ icm4: 16,
+ icm8: 16,
+ // s => 16 x 16
+ 'ics#': 16,
+ ics4: 16,
+ ics8: 16,
+ is32: 16,
+ s8mk: 16,
+ icp4: 16,
+ // l => 32 x 32
+ icl4: 32,
+ icl8: 32,
+ il32: 32,
+ l8mk: 32,
+ icp5: 32,
+ ic11: 32,
+ // h => 48 x 48
+ ich4: 48,
+ ich8: 48,
+ ih32: 48,
+ h8mk: 48,
+ // . => 64 x 64
+ icp6: 64,
+ ic12: 32,
+ // t => 128 x 128
+ it32: 128,
+ t8mk: 128,
+ ic07: 128,
+ // . => 256 x 256
+ ic08: 256,
+ ic13: 256,
+ // . => 512 x 512
+ ic09: 512,
+ ic14: 512,
+ // . => 1024 x 1024
+ ic10: 1024,
+}
+
+function readImageHeader(
+ input: Uint8Array,
+ imageOffset: number,
+): [string, number] {
+ const imageLengthOffset = imageOffset + ENTRY_LENGTH_OFFSET
+ return [
+ toUTF8String(input, imageOffset, imageLengthOffset),
+ readUInt32BE(input, imageLengthOffset),
+ ]
+}
+
+function getImageSize(type: string): ISize {
+ const size = ICON_TYPE_SIZE[type]
+ return { width: size, height: size, type }
+}
+
+export const ICNS: IImage = {
+ validate: (input) => toUTF8String(input, 0, 4) === 'icns',
+
+ calculate(input) {
+ const inputLength = input.length
+ const fileLength = readUInt32BE(input, FILE_LENGTH_OFFSET)
+ let imageOffset = SIZE_HEADER
+
+ let imageHeader = readImageHeader(input, imageOffset)
+ let imageSize = getImageSize(imageHeader[0])
+ imageOffset += imageHeader[1]
+
+ if (imageOffset === fileLength) return imageSize
+
+ const result = {
+ height: imageSize.height,
+ images: [imageSize],
+ width: imageSize.width,
+ }
+
+ while (imageOffset < fileLength && imageOffset < inputLength) {
+ imageHeader = readImageHeader(input, imageOffset)
+ imageSize = getImageSize(imageHeader[0])
+ imageOffset += imageHeader[1]
+ result.images.push(imageSize)
+ }
+
+ return result
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts b/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts
new file mode 100644
index 000000000..42021544c
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/ico.ts
@@ -0,0 +1,75 @@
+import type { IImage, ISize } from './interface.ts'
+import { readUInt16LE } from './utils.js'
+
+const TYPE_ICON = 1
+
+/**
+ * ICON Header
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 2 | Reserved. Must always be 0. |
+ * | 2 | 2 | Image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid. |
+ * | 4 | 2 | Number of images in the file. |
+ *
+ */
+const SIZE_HEADER = 2 + 2 + 2 // 6
+
+/**
+ * Image Entry
+ *
+ * | Offset | Size | Purpose |
+ * | 0 | 1 | Image width in pixels. Can be any number between 0 and 255. Value 0 means width is 256 pixels. |
+ * | 1 | 1 | Image height in pixels. Can be any number between 0 and 255. Value 0 means height is 256 pixels. |
+ * | 2 | 1 | Number of colors in the color palette. Should be 0 if the image does not use a color palette. |
+ * | 3 | 1 | Reserved. Should be 0. |
+ * | 4 | 2 | ICO format: Color planes. Should be 0 or 1. |
+ * | | | CUR format: The horizontal coordinates of the hotspot in number of pixels from the left. |
+ * | 6 | 2 | ICO format: Bits per pixel. |
+ * | | | CUR format: The vertical coordinates of the hotspot in number of pixels from the top. |
+ * | 8 | 4 | The size of the image's data in bytes |
+ * | 12 | 4 | The offset of BMP or PNG data from the beginning of the ICO/CUR file |
+ *
+ */
+const SIZE_IMAGE_ENTRY = 1 + 1 + 1 + 1 + 2 + 2 + 4 + 4 // 16
+
+function getSizeFromOffset(input: Uint8Array, offset: number): number {
+ const value = input[offset]
+ return value === 0 ? 256 : value
+}
+
+function getImageSize(input: Uint8Array, imageIndex: number): ISize {
+ const offset = SIZE_HEADER + imageIndex * SIZE_IMAGE_ENTRY
+ return {
+ height: getSizeFromOffset(input, offset + 1),
+ width: getSizeFromOffset(input, offset),
+ }
+}
+
+export const ICO: IImage = {
+ validate(input) {
+ const reserved = readUInt16LE(input, 0)
+ const imageCount = readUInt16LE(input, 4)
+ if (reserved !== 0 || imageCount === 0) return false
+
+ const imageType = readUInt16LE(input, 2)
+ return imageType === TYPE_ICON
+ },
+
+ calculate(input) {
+ const nbImages = readUInt16LE(input, 4)
+ const imageSize = getImageSize(input, 0)
+
+ if (nbImages === 1) return imageSize
+
+ const imgs: ISize[] = [imageSize]
+ for (let imageIndex = 1; imageIndex < nbImages; imageIndex += 1) {
+ imgs.push(getImageSize(input, imageIndex))
+ }
+
+ return {
+ height: imageSize.height,
+ images: imgs,
+ width: imageSize.width,
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/index.ts b/packages/astro/src/assets/utils/vendor/image-size/types/index.ts
new file mode 100644
index 000000000..f5f5fb34b
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/index.ts
@@ -0,0 +1,44 @@
+// load all available handlers explicitly for browserify support
+import { BMP } from './bmp.js'
+import { CUR } from './cur.js'
+import { DDS } from './dds.js'
+import { GIF } from './gif.js'
+import { HEIF } from './heif.js'
+import { ICNS } from './icns.js'
+import { ICO } from './ico.js'
+import { J2C } from './j2c.js'
+import { JP2 } from './jp2.js'
+import { JPG } from './jpg.js'
+import { KTX } from './ktx.js'
+import { PNG } from './png.js'
+import { PNM } from './pnm.js'
+import { PSD } from './psd.js'
+import { SVG } from './svg.js'
+import { TGA } from './tga.js'
+import { TIFF } from './tiff.js'
+import { WEBP } from './webp.js'
+
+export const typeHandlers = new Map([
+ ['bmp', BMP],
+ ['cur', CUR],
+ ['dds', DDS],
+ ['gif', GIF],
+ ['heif', HEIF],
+ ['icns', ICNS],
+ ['ico', ICO],
+ ['j2c', J2C],
+ ['jp2', JP2],
+ ['jpg', JPG],
+ ['ktx', KTX],
+ ['png', PNG],
+ ['pnm', PNM],
+ ['psd', PSD],
+ ['svg', SVG],
+ ['tga', TGA],
+ ['tiff', TIFF],
+ ['webp', WEBP],
+] as const)
+
+
+export const types = Array.from(typeHandlers.keys())
+export type imageType = typeof types[number]
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts b/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts
new file mode 100644
index 000000000..4450c87a9
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/interface.ts
@@ -0,0 +1,15 @@
+export type ISize = {
+ width: number | undefined
+ height: number | undefined
+ orientation?: number
+ type?: string
+}
+
+export type ISizeCalculationResult = {
+ images?: ISize[]
+} & ISize
+
+export type IImage = {
+ validate: (input: Uint8Array) => boolean
+ calculate: (input: Uint8Array) => ISizeCalculationResult
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts b/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts
new file mode 100644
index 000000000..77a9ae7be
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/j2c.ts
@@ -0,0 +1,12 @@
+import type { IImage } from './interface.ts'
+import { toHexString, readUInt32BE } from './utils.js'
+
+export const J2C: IImage = {
+ // TODO: this doesn't seem right. SIZ marker doesn't have to be right after the SOC
+ validate: (input) => toHexString(input, 0, 4) === 'ff4fff51',
+
+ calculate: (input) => ({
+ height: readUInt32BE(input, 12),
+ width: readUInt32BE(input, 8),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts b/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts
new file mode 100644
index 000000000..683540f1f
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/jp2.ts
@@ -0,0 +1,23 @@
+import type { IImage } from './interface.ts'
+import { readUInt32BE, findBox } from './utils.js'
+
+export const JP2: IImage = {
+ validate(input) {
+ if (readUInt32BE(input, 4) !== 0x6a502020 || readUInt32BE(input, 0) < 1) return false
+ const ftypBox = findBox(input, 'ftyp', 0)
+ if (!ftypBox) return false
+ return readUInt32BE(input, ftypBox.offset + 4) === 0x66747970
+ },
+
+ calculate(input) {
+ const jp2hBox = findBox(input, 'jp2h', 0)
+ const ihdrBox = jp2hBox && findBox(input, 'ihdr', jp2hBox.offset + 8)
+ if (ihdrBox) {
+ return {
+ height: readUInt32BE(input, ihdrBox.offset + 8),
+ width: readUInt32BE(input, ihdrBox.offset + 12),
+ }
+ }
+ throw new TypeError('Unsupported JPEG 2000 format')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts b/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts
new file mode 100644
index 000000000..763cfc98c
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/jpg.ts
@@ -0,0 +1,162 @@
+// NOTE: we only support baseline and progressive JPGs here
+// due to the structure of the loader class, we only get a buffer
+// with a maximum size of 4096 bytes. so if the SOF marker is outside
+// if this range we can't detect the file size correctly.
+
+import type { IImage, ISize } from './interface.ts'
+import { readUInt, readUInt16BE, toHexString } from './utils.js'
+
+const EXIF_MARKER = '45786966'
+const APP1_DATA_SIZE_BYTES = 2
+const EXIF_HEADER_BYTES = 6
+const TIFF_BYTE_ALIGN_BYTES = 2
+const BIG_ENDIAN_BYTE_ALIGN = '4d4d'
+const LITTLE_ENDIAN_BYTE_ALIGN = '4949'
+
+// Each entry is exactly 12 bytes
+const IDF_ENTRY_BYTES = 12
+const NUM_DIRECTORY_ENTRIES_BYTES = 2
+
+function isEXIF(input: Uint8Array): boolean {
+ return toHexString(input, 2, 6) === EXIF_MARKER
+}
+
+function extractSize(input: Uint8Array, index: number): ISize {
+ return {
+ height: readUInt16BE(input, index),
+ width: readUInt16BE(input, index + 2),
+ }
+}
+
+function extractOrientation(exifBlock: Uint8Array, isBigEndian: boolean) {
+ // TODO: assert that this contains 0x002A
+ // let STATIC_MOTOROLA_TIFF_HEADER_BYTES = 2
+ // let TIFF_IMAGE_FILE_DIRECTORY_BYTES = 4
+
+ // TODO: derive from TIFF_IMAGE_FILE_DIRECTORY_BYTES
+ const idfOffset = 8
+
+ // IDF osset works from right after the header bytes
+ // (so the offset includes the tiff byte align)
+ const offset = EXIF_HEADER_BYTES + idfOffset
+
+ const idfDirectoryEntries = readUInt(exifBlock, 16, offset, isBigEndian)
+
+ for (
+ let directoryEntryNumber = 0;
+ directoryEntryNumber < idfDirectoryEntries;
+ directoryEntryNumber++
+ ) {
+ const start =
+ offset +
+ NUM_DIRECTORY_ENTRIES_BYTES +
+ directoryEntryNumber * IDF_ENTRY_BYTES
+ const end = start + IDF_ENTRY_BYTES
+
+ // Skip on corrupt EXIF blocks
+ if (start > exifBlock.length) {
+ return
+ }
+
+ const block = exifBlock.slice(start, end)
+ const tagNumber = readUInt(block, 16, 0, isBigEndian)
+
+ // 0x0112 (decimal: 274) is the `orientation` tag ID
+ if (tagNumber === 274) {
+ const dataFormat = readUInt(block, 16, 2, isBigEndian)
+ if (dataFormat !== 3) {
+ return
+ }
+
+ // unsigned int has 2 bytes per component
+ // if there would more than 4 bytes in total it's a pointer
+ const numberOfComponents = readUInt(block, 32, 4, isBigEndian)
+ if (numberOfComponents !== 1) {
+ return
+ }
+
+ return readUInt(block, 16, 8, isBigEndian)
+ }
+ }
+}
+
+function validateExifBlock(input: Uint8Array, index: number) {
+ // Skip APP1 Data Size
+ const exifBlock = input.slice(APP1_DATA_SIZE_BYTES, index)
+
+ // Consider byte alignment
+ const byteAlign = toHexString(
+ exifBlock,
+ EXIF_HEADER_BYTES,
+ EXIF_HEADER_BYTES + TIFF_BYTE_ALIGN_BYTES,
+ )
+
+ // Ignore Empty EXIF. Validate byte alignment
+ const isBigEndian = byteAlign === BIG_ENDIAN_BYTE_ALIGN
+ const isLittleEndian = byteAlign === LITTLE_ENDIAN_BYTE_ALIGN
+
+ if (isBigEndian || isLittleEndian) {
+ return extractOrientation(exifBlock, isBigEndian)
+ }
+}
+
+function validateInput(input: Uint8Array, index: number): void {
+ // index should be within buffer limits
+ if (index > input.length) {
+ throw new TypeError('Corrupt JPG, exceeded buffer limits')
+ }
+}
+
+export const JPG: IImage = {
+ validate: (input) => toHexString(input, 0, 2) === 'ffd8',
+
+ calculate(input) {
+ // Skip 4 chars, they are for signature
+ input = input.slice(4)
+
+ let orientation: number | undefined
+ let next: number
+ while (input.length) {
+ // read length of the next block
+ const i = readUInt16BE(input, 0)
+
+ // Every JPEG block must begin with a 0xFF
+ if (input[i] !== 0xff) {
+ // Change from upstream: fix non-0xFF blocks skipping
+ input = input.slice(i)
+ continue
+ }
+
+ if (isEXIF(input)) {
+ orientation = validateExifBlock(input, i)
+ }
+
+ // ensure correct format
+ validateInput(input, i)
+
+ // 0xFFC0 is baseline standard(SOF)
+ // 0xFFC1 is baseline optimized(SOF)
+ // 0xFFC2 is progressive(SOF2)
+ next = input[i + 1]
+ if (next === 0xc0 || next === 0xc1 || next === 0xc2) {
+ const size = extractSize(input, i + 5)
+
+ // TODO: is orientation=0 a valid answer here?
+ if (!orientation) {
+ return size
+ }
+
+ return {
+ height: size.height,
+ orientation,
+ width: size.width,
+ }
+ }
+
+ // move to the next block
+ input = input.slice(i + 2)
+ }
+
+ throw new TypeError('Invalid JPG, no size found')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts b/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts
new file mode 100644
index 000000000..6dc5a95ff
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/ktx.ts
@@ -0,0 +1,19 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32LE } from './utils.js'
+
+export const KTX: IImage = {
+ validate: (input) => {
+ const signature = toUTF8String(input, 1, 7)
+ return ['KTX 11', 'KTX 20'].includes(signature)
+ },
+
+ calculate: (input) => {
+ const type = input[5] === 0x31 ? 'ktx' : 'ktx2'
+ const offset = type === 'ktx' ? 36 : 20
+ return ({
+ height: readUInt32LE(input, offset + 4),
+ width: readUInt32LE(input, offset),
+ type,
+ })
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/png.ts b/packages/astro/src/assets/utils/vendor/image-size/types/png.ts
new file mode 100644
index 000000000..768a7f39e
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/png.ts
@@ -0,0 +1,37 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+const pngSignature = 'PNG\r\n\x1a\n'
+const pngImageHeaderChunkName = 'IHDR'
+
+// Used to detect "fried" png's: http://www.jongware.com/pngdefry.html
+const pngFriedChunkName = 'CgBI'
+
+export const PNG: IImage = {
+ validate(input) {
+ if (pngSignature === toUTF8String(input, 1, 8)) {
+ let chunkName = toUTF8String(input, 12, 16)
+ if (chunkName === pngFriedChunkName) {
+ chunkName = toUTF8String(input, 28, 32)
+ }
+ if (chunkName !== pngImageHeaderChunkName) {
+ throw new TypeError('Invalid PNG')
+ }
+ return true
+ }
+ return false
+ },
+
+ calculate(input) {
+ if (toUTF8String(input, 12, 16) === pngFriedChunkName) {
+ return {
+ height: readUInt32BE(input, 36),
+ width: readUInt32BE(input, 32),
+ }
+ }
+ return {
+ height: readUInt32BE(input, 20),
+ width: readUInt32BE(input, 16),
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts b/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts
new file mode 100644
index 000000000..3f489f2af
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/pnm.ts
@@ -0,0 +1,80 @@
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String } from './utils.js'
+
+const PNMTypes = {
+ P1: 'pbm/ascii',
+ P2: 'pgm/ascii',
+ P3: 'ppm/ascii',
+ P4: 'pbm',
+ P5: 'pgm',
+ P6: 'ppm',
+ P7: 'pam',
+ PF: 'pfm',
+} as const
+
+type ValidSignature = keyof typeof PNMTypes
+type Handler = (type: string[]) => ISize
+
+const handlers: { [type: string]: Handler } = {
+ default: (lines) => {
+ let dimensions: string[] = []
+
+ while (lines.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ const line = lines.shift() as string
+ if (line[0] === '#') {
+ continue
+ }
+ dimensions = line.split(' ')
+ break
+ }
+
+ if (dimensions.length === 2) {
+ return {
+ height: parseInt(dimensions[1], 10),
+ width: parseInt(dimensions[0], 10),
+ }
+ } else {
+ throw new TypeError('Invalid PNM')
+ }
+ },
+ pam: (lines) => {
+ const size: { [key: string]: number } = {}
+ while (lines.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ const line = lines.shift() as string
+ if (line.length > 16 || line.charCodeAt(0) > 128) {
+ continue
+ }
+ const [key, value] = line.split(' ')
+ if (key && value) {
+ size[key.toLowerCase()] = parseInt(value, 10)
+ }
+ if (size.height && size.width) {
+ break
+ }
+ }
+
+ if (size.height && size.width) {
+ return {
+ height: size.height,
+ width: size.width,
+ }
+ } else {
+ throw new TypeError('Invalid PAM')
+ }
+ },
+}
+
+export const PNM: IImage = {
+ validate: (input) => toUTF8String(input, 0, 2) in PNMTypes,
+
+ calculate(input) {
+ const signature = toUTF8String(input, 0, 2) as ValidSignature
+ const type = PNMTypes[signature]
+ // TODO: this probably generates garbage. move to a stream based parser
+ const lines = toUTF8String(input, 3).split(/[\r\n]+/)
+ const handler = handlers[type] || handlers.default
+ return handler(lines)
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts b/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts
new file mode 100644
index 000000000..b260e4abc
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/psd.ts
@@ -0,0 +1,11 @@
+import type { IImage } from './interface.ts'
+import { toUTF8String, readUInt32BE } from './utils.js'
+
+export const PSD: IImage = {
+ validate: (input) => toUTF8String(input, 0, 4) === '8BPS',
+
+ calculate: (input) => ({
+ height: readUInt32BE(input, 14),
+ width: readUInt32BE(input, 18),
+ }),
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts b/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts
new file mode 100644
index 000000000..ea5a05ac1
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/svg.ts
@@ -0,0 +1,109 @@
+/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */
+
+import type { IImage, ISize } from './interface.ts'
+import { toUTF8String } from './utils.js'
+
+type IAttributes = {
+ width: number | null
+ height: number | null
+ viewbox?: IAttributes | null
+}
+
+const svgReg = /<svg\s([^>"']|"[^"]*"|'[^']*')*>/
+
+const extractorRegExps = {
+ height: /\sheight=(['"])([^%]+?)\1/,
+ root: svgReg,
+ viewbox: /\sviewBox=(['"])(.+?)\1/i,
+ width: /\swidth=(['"])([^%]+?)\1/,
+}
+
+const INCH_CM = 2.54
+const units: { [unit: string]: number } = {
+ in: 96,
+ cm: 96 / INCH_CM,
+ em: 16,
+ ex: 8,
+ m: (96 / INCH_CM) * 100,
+ mm: 96 / INCH_CM / 10,
+ pc: 96 / 72 / 12,
+ pt: 96 / 72,
+ px: 1,
+}
+
+const unitsReg = new RegExp(
+ `^([0-9.]+(?:e\\d+)?)(${Object.keys(units).join('|')})?$`,
+)
+
+function parseLength(len: string) {
+ const m = unitsReg.exec(len)
+ if (!m) {
+ return undefined
+ }
+ return Math.round(Number(m[1]) * (units[m[2]] || 1))
+}
+
+function parseViewbox(viewbox: string): IAttributes {
+ const bounds = viewbox.split(' ')
+ return {
+ height: parseLength(bounds[3]) as number,
+ width: parseLength(bounds[2]) as number,
+ }
+}
+
+function parseAttributes(root: string): IAttributes {
+ const width = extractorRegExps.width.exec(root)
+ const height = extractorRegExps.height.exec(root)
+ const viewbox = extractorRegExps.viewbox.exec(root)
+ return {
+ height: height && (parseLength(height[2]) as number),
+ viewbox: viewbox && (parseViewbox(viewbox[2]) as IAttributes),
+ width: width && (parseLength(width[2]) as number),
+ }
+}
+
+function calculateByDimensions(attrs: IAttributes): ISize {
+ return {
+ height: attrs.height as number,
+ width: attrs.width as number,
+ }
+}
+
+function calculateByViewbox(attrs: IAttributes, viewbox: IAttributes): ISize {
+ const ratio = (viewbox.width as number) / (viewbox.height as number)
+ if (attrs.width) {
+ return {
+ height: Math.floor(attrs.width / ratio),
+ width: attrs.width,
+ }
+ }
+ if (attrs.height) {
+ return {
+ height: attrs.height,
+ width: Math.floor(attrs.height * ratio),
+ }
+ }
+ return {
+ height: viewbox.height as number,
+ width: viewbox.width as number,
+ }
+}
+
+export const SVG: IImage = {
+ // Scan only the first kilo-byte to speed up the check on larger files
+ validate: (input) => svgReg.test(toUTF8String(input, 0, 1000)),
+
+ calculate(input) {
+ const root = extractorRegExps.root.exec(toUTF8String(input))
+ if (root) {
+ const attrs = parseAttributes(root[0])
+ if (attrs.width && attrs.height) {
+ return calculateByDimensions(attrs)
+ }
+ if (attrs.viewbox) {
+ return calculateByViewbox(attrs, attrs.viewbox)
+ }
+ }
+ throw new TypeError('Invalid SVG')
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts b/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts
new file mode 100644
index 000000000..1fd504b55
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/tga.ts
@@ -0,0 +1,15 @@
+import type { IImage } from './interface.ts'
+import { readUInt16LE } from './utils.js'
+
+export const TGA: IImage = {
+ validate(input) {
+ return readUInt16LE(input, 0) === 0 && readUInt16LE(input, 4) === 0
+ },
+
+ calculate(input) {
+ return {
+ height: readUInt16LE(input, 14),
+ width: readUInt16LE(input, 12),
+ }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts b/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts
new file mode 100644
index 000000000..c8590f11f
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/tiff.ts
@@ -0,0 +1,93 @@
+// based on http://www.compix.com/fileformattif.htm
+// TO-DO: support big-endian as well
+import type { IImage } from './interface.ts'
+import { readUInt, toHexString, toUTF8String } from './utils.js'
+
+// Read IFD (image-file-directory) into a buffer
+function readIFD(input: Uint8Array, isBigEndian: boolean) {
+ const ifdOffset = readUInt(input, 32, 4, isBigEndian)
+ return input.slice(ifdOffset + 2)
+}
+
+// TIFF values seem to be messed up on Big-Endian, this helps
+function readValue(input: Uint8Array, isBigEndian: boolean): number {
+ const low = readUInt(input, 16, 8, isBigEndian)
+ const high = readUInt(input, 16, 10, isBigEndian)
+ return (high << 16) + low
+}
+
+// move to the next tag
+function nextTag(input: Uint8Array) {
+ if (input.length > 24) {
+ return input.slice(12)
+ }
+}
+
+// Extract IFD tags from TIFF metadata
+function extractTags(input: Uint8Array, isBigEndian: boolean) {
+ const tags: { [key: number]: number } = {}
+
+ let temp: Uint8Array | undefined = input
+ while (temp && temp.length) {
+ const code = readUInt(temp, 16, 0, isBigEndian)
+ const type = readUInt(temp, 16, 2, isBigEndian)
+ const length = readUInt(temp, 32, 4, isBigEndian)
+
+ // 0 means end of IFD
+ if (code === 0) {
+ break
+ } else {
+ // 256 is width, 257 is height
+ // if (code === 256 || code === 257) {
+ if (length === 1 && (type === 3 || type === 4)) {
+ tags[code] = readValue(temp, isBigEndian)
+ }
+
+ // move to the next tag
+ temp = nextTag(temp)
+ }
+ }
+
+ return tags
+}
+
+// Test if the TIFF is Big Endian or Little Endian
+function determineEndianness(input: Uint8Array) {
+ const signature = toUTF8String(input, 0, 2)
+ if ('II' === signature) {
+ return 'LE'
+ } else if ('MM' === signature) {
+ return 'BE'
+ }
+}
+
+const signatures = [
+ // '492049', // currently not supported
+ '49492a00', // Little endian
+ '4d4d002a', // Big Endian
+ // '4d4d002a', // BigTIFF > 4GB. currently not supported
+]
+
+export const TIFF: IImage = {
+ validate: (input) => signatures.includes(toHexString(input, 0, 4)),
+
+ calculate(input) {
+ // Determine BE/LE
+ const isBigEndian = determineEndianness(input) === 'BE'
+
+ // read the IFD
+ const ifdBuffer = readIFD(input, isBigEndian)
+
+ // extract the tags from the IFD
+ const tags = extractTags(ifdBuffer, isBigEndian)
+
+ const width = tags[256]
+ const height = tags[257]
+
+ if (!width || !height) {
+ throw new TypeError('Invalid Tiff. Missing tags')
+ }
+
+ return { height, width }
+ },
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts b/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts
new file mode 100644
index 000000000..c7f87da9a
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/utils.ts
@@ -0,0 +1,84 @@
+const decoder = new TextDecoder()
+export const toUTF8String = (
+ input: Uint8Array,
+ start = 0,
+ end = input.length,
+) => decoder.decode(input.slice(start, end))
+
+export const toHexString = (input: Uint8Array, start = 0, end = input.length) =>
+ input
+ .slice(start, end)
+ .reduce((memo, i) => memo + ('0' + i.toString(16)).slice(-2), '')
+
+export const readInt16LE = (input: Uint8Array, offset = 0) => {
+ const val = input[offset] + input[offset + 1] * 2 ** 8
+ return val | ((val & (2 ** 15)) * 0x1fffe)
+}
+
+export const readUInt16BE = (input: Uint8Array, offset = 0) =>
+ input[offset] * 2 ** 8 + input[offset + 1]
+
+export const readUInt16LE = (input: Uint8Array, offset = 0) =>
+ input[offset] + input[offset + 1] * 2 ** 8
+
+export const readUInt24LE = (input: Uint8Array, offset = 0) =>
+ input[offset] + input[offset + 1] * 2 ** 8 + input[offset + 2] * 2 ** 16
+
+export const readInt32LE = (input: Uint8Array, offset = 0) =>
+ input[offset] +
+ input[offset + 1] * 2 ** 8 +
+ input[offset + 2] * 2 ** 16 +
+ (input[offset + 3] << 24)
+
+export const readUInt32BE = (input: Uint8Array, offset = 0) =>
+ input[offset] * 2 ** 24 +
+ input[offset + 1] * 2 ** 16 +
+ input[offset + 2] * 2 ** 8 +
+ input[offset + 3]
+
+export const readUInt32LE = (input: Uint8Array, offset = 0) =>
+ input[offset] +
+ input[offset + 1] * 2 ** 8 +
+ input[offset + 2] * 2 ** 16 +
+ input[offset + 3] * 2 ** 24
+
+// Abstract reading multi-byte unsigned integers
+const methods = {
+ readUInt16BE,
+ readUInt16LE,
+ readUInt32BE,
+ readUInt32LE,
+} as const
+
+type MethodName = keyof typeof methods
+export function readUInt(
+ input: Uint8Array,
+ bits: 16 | 32,
+ offset: number,
+ isBigEndian: boolean,
+): number {
+ offset = offset || 0
+ const endian = isBigEndian ? 'BE' : 'LE'
+ const methodName: MethodName = ('readUInt' + bits + endian) as MethodName
+ return methods[methodName](input, offset)
+}
+
+function readBox(buffer: Uint8Array, offset: number) {
+ if (buffer.length - offset < 4) return
+ const boxSize = readUInt32BE(buffer, offset)
+ if (buffer.length - offset < boxSize) return
+ return {
+ name: toUTF8String(buffer, 4 + offset, 8 + offset),
+ offset,
+ size: boxSize,
+ }
+}
+
+export function findBox(buffer: Uint8Array, boxName: string, offset: number) {
+ while (offset < buffer.length) {
+ const box = readBox(buffer, offset)
+ if (!box) break
+ if (box.name === boxName) return box
+ offset += box.size
+ }
+}
diff --git a/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts b/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts
new file mode 100644
index 000000000..79291df05
--- /dev/null
+++ b/packages/astro/src/assets/utils/vendor/image-size/types/webp.ts
@@ -0,0 +1,68 @@
+// based on https://developers.google.com/speed/webp/docs/riff_container
+import type { IImage, ISize } from './interface.ts'
+import { toHexString, toUTF8String, readInt16LE, readUInt24LE } from './utils.js'
+
+function calculateExtended(input: Uint8Array): ISize {
+ return {
+ height: 1 + readUInt24LE(input, 7),
+ width: 1 + readUInt24LE(input, 4),
+ }
+}
+
+function calculateLossless(input: Uint8Array): ISize {
+ return {
+ height:
+ 1 +
+ (((input[4] & 0xf) << 10) | (input[3] << 2) | ((input[2] & 0xc0) >> 6)),
+ width: 1 + (((input[2] & 0x3f) << 8) | input[1]),
+ }
+}
+
+function calculateLossy(input: Uint8Array): ISize {
+ // `& 0x3fff` returns the last 14 bits
+ // TO-DO: include webp scaling in the calculations
+ return {
+ height: readInt16LE(input, 8) & 0x3fff,
+ width: readInt16LE(input, 6) & 0x3fff,
+ }
+}
+
+export const WEBP: IImage = {
+ validate(input) {
+ const riffHeader = 'RIFF' === toUTF8String(input, 0, 4)
+ const webpHeader = 'WEBP' === toUTF8String(input, 8, 12)
+ const vp8Header = 'VP8' === toUTF8String(input, 12, 15)
+ return riffHeader && webpHeader && vp8Header
+ },
+
+ calculate(input) {
+ const chunkHeader = toUTF8String(input, 12, 16)
+ input = input.slice(20, 30)
+
+ // Extended webp stream signature
+ if (chunkHeader === 'VP8X') {
+ const extendedHeader = input[0]
+ const validStart = (extendedHeader & 0xc0) === 0
+ const validEnd = (extendedHeader & 0x01) === 0
+ if (validStart && validEnd) {
+ return calculateExtended(input)
+ } else {
+ // TODO: breaking change
+ throw new TypeError('Invalid WebP')
+ }
+ }
+
+ // Lossless webp stream signature
+ if (chunkHeader === 'VP8 ' && input[0] !== 0x2f) {
+ return calculateLossy(input)
+ }
+
+ // Lossy webp stream signature
+ const signature = toHexString(input, 3, 6)
+ if (chunkHeader === 'VP8L' && signature !== '9d012a') {
+ return calculateLossless(input)
+ }
+
+ throw new TypeError('Invalid WebP')
+ },
+}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
new file mode 100644
index 000000000..7d6cd3828
--- /dev/null
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -0,0 +1,266 @@
+import type * as fsMod from 'node:fs';
+import { extname } from 'node:path';
+import MagicString from 'magic-string';
+import type * as vite from 'vite';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import type { Logger } from '../core/logger/core.js';
+import {
+ appendForwardSlash,
+ joinPaths,
+ prependForwardSlash,
+ removeBase,
+ removeQueryString,
+} from '../core/path.js';
+import { normalizePath } from '../core/viteUtils.js';
+import type { AstroSettings } from '../types/astro.js';
+import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './consts.js';
+import { fontsPlugin } from './fonts/vite-plugin-fonts.js';
+import type { ImageTransform } from './types.js';
+import { getAssetsPrefix } from './utils/getAssetsPrefix.js';
+import { isESMImportedImage } from './utils/imageKind.js';
+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';
+
+const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
+
+const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})`, 'i');
+const assetRegexEnds = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})$`, 'i');
+const addStaticImageFactory = (
+ settings: AstroSettings,
+): typeof globalThis.astroAsset.addStaticImage => {
+ return (options, hashProperties, originalFSPath) => {
+ if (!globalThis.astroAsset.staticImages) {
+ globalThis.astroAsset.staticImages = new Map<
+ string,
+ {
+ originalSrcPath: string;
+ transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
+ }
+ >();
+ }
+
+ // Rollup will copy the file to the output directory, as such this is the path in the output directory, including the asset prefix / base
+ const ESMImportedImageSrc = isESMImportedImage(options.src) ? options.src.src : options.src;
+ const fileExtension = extname(ESMImportedImageSrc);
+ const assetPrefix = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);
+
+ // This is the path to the original image, from the dist root, without the base or the asset prefix (e.g. /_astro/image.hash.png)
+ const finalOriginalPath = removeBase(
+ removeBase(ESMImportedImageSrc, settings.config.base),
+ assetPrefix,
+ );
+
+ const hash = hashTransform(options, settings.config.image.service.entrypoint, hashProperties);
+
+ let finalFilePath: string;
+ let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath);
+ const transformForHash = transformsForPath?.transforms.get(hash);
+
+ // If the same image has already been transformed with the same options, we'll reuse the final path
+ if (transformsForPath && transformForHash) {
+ finalFilePath = transformForHash.finalPath;
+ } else {
+ finalFilePath = prependForwardSlash(
+ joinPaths(
+ isESMImportedImage(options.src) ? '' : settings.config.build.assets,
+ prependForwardSlash(propsToFilename(finalOriginalPath, options, hash)),
+ ),
+ );
+
+ if (!transformsForPath) {
+ globalThis.astroAsset.staticImages.set(finalOriginalPath, {
+ originalSrcPath: originalFSPath,
+ transforms: new Map(),
+ });
+ transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath)!;
+ }
+
+ transformsForPath.transforms.set(hash, {
+ finalPath: finalFilePath,
+ transform: options,
+ });
+ }
+
+ // The paths here are used for URLs, so we need to make sure they have the proper format for an URL
+ // (leading slash, prefixed with the base / assets prefix, encoded, etc)
+ if (settings.config.build.assetsPrefix) {
+ return encodeURI(joinPaths(assetPrefix, finalFilePath));
+ } else {
+ return encodeURI(prependForwardSlash(joinPaths(settings.config.base, finalFilePath)));
+ }
+ };
+};
+
+interface Options {
+ settings: AstroSettings;
+ sync: boolean;
+ logger: Logger;
+ fs: typeof fsMod;
+}
+
+export default function assets({ fs, settings, sync, logger }: Options): vite.Plugin[] {
+ let resolvedConfig: vite.ResolvedConfig;
+ let shouldEmitFile = false;
+ let isBuild = false;
+
+ globalThis.astroAsset = {
+ referencedImages: new Set(),
+ };
+
+ const imageComponentPrefix =
+ settings.config.experimental.responsiveImages && settings.config.image.experimentalDefaultStyles
+ ? 'Responsive'
+ : '';
+ return [
+ // Expose the components and different utilities from `astro:assets`
+ {
+ name: 'astro:assets',
+ config(_, env) {
+ isBuild = env.command === 'build';
+ },
+ async resolveId(id) {
+ if (id === VIRTUAL_SERVICE_ID) {
+ return await this.resolve(settings.config.image.service.entrypoint);
+ }
+ if (id === VIRTUAL_MODULE_ID) {
+ return resolvedVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id === resolvedVirtualModuleId) {
+ return {
+ code: `
+ export { getConfiguredImageService, isLocalService } from "astro/assets";
+ 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 Font } from "astro/components/Font.astro";
+ export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";
+
+ export const imageConfig = ${JSON.stringify({ ...settings.config.image, experimentalResponsiveImages: settings.config.experimental.responsiveImages })};
+ // This is used by the @astrojs/node integration to locate images.
+ // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
+ // new URL("dist/...") is interpreted by the bundler as a signal to include that directory
+ // in the Lambda bundle, which would bloat the bundle with images.
+ // To prevent this, we mark the URL construction as pure,
+ // so that it's tree-shaken away for all platforms that don't need it.
+ export const outDir = /* #__PURE__ */ new URL(${JSON.stringify(
+ new URL(
+ settings.buildOutput === 'server'
+ ? settings.config.build.client
+ : settings.config.outDir,
+ ),
+ )});
+ export const assetsDir = /* #__PURE__ */ new URL(${JSON.stringify(
+ settings.config.build.assets,
+ )}, outDir);
+ export const getImage = async (options) => await getImageInternal(options, imageConfig);
+ `,
+ };
+ }
+ },
+ buildStart() {
+ if (!isBuild) return;
+ globalThis.astroAsset.addStaticImage = addStaticImageFactory(settings);
+ },
+ // In build, rewrite paths to ESM imported images in code to their final location
+ async renderChunk(code) {
+ const assetUrlRE = /__ASTRO_ASSET_IMAGE__([\w$]+)__(?:_(.*?)__)?/g;
+
+ let match;
+ let s;
+ while ((match = assetUrlRE.exec(code))) {
+ s = s || (s = new MagicString(code));
+ const [full, hash, postfix = ''] = match;
+
+ const file = this.getFileName(hash);
+ const fileExtension = extname(file);
+ const pf = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);
+ const prefix = pf ? appendForwardSlash(pf) : resolvedConfig.base;
+ const outputFilepath = prefix + normalizePath(file + postfix);
+
+ s.overwrite(match.index, match.index + full.length, outputFilepath);
+ }
+
+ if (s) {
+ return {
+ code: s.toString(),
+ map: resolvedConfig.build.sourcemap ? s.generateMap({ hires: 'boundary' }) : null,
+ };
+ } else {
+ return null;
+ }
+ },
+ },
+ // Return a more advanced shape for images imported in ESM
+ {
+ name: 'astro:assets:esm',
+ enforce: 'pre',
+ config(_, env) {
+ shouldEmitFile = env.command === 'build';
+ },
+ configResolved(viteConfig) {
+ resolvedConfig = viteConfig;
+ },
+ async load(id, options) {
+ if (assetRegex.test(id)) {
+ if (!globalThis.astroAsset.referencedImages)
+ globalThis.astroAsset.referencedImages = new Set();
+
+ if (id !== removeQueryString(id)) {
+ // If our import has any query params, we'll let Vite handle it, nonetheless we'll make sure to not delete it
+ // See https://github.com/withastro/astro/issues/8333
+ globalThis.astroAsset.referencedImages.add(removeQueryString(id));
+ return;
+ }
+
+ // If the requested ID doesn't end with a valid image extension, we'll let Vite handle it
+ if (!assetRegexEnds.test(id)) {
+ return;
+ }
+
+ const emitFile = shouldEmitFile ? this.emitFile.bind(this) : undefined;
+ const imageMetadata = await emitESMImage(
+ id,
+ this.meta.watchMode,
+ id.endsWith('.svg'),
+ emitFile,
+ );
+
+ if (!imageMetadata) {
+ throw new AstroError({
+ ...AstroErrorData.ImageNotFound,
+ message: AstroErrorData.ImageNotFound.message(id),
+ });
+ }
+
+ if (id.endsWith('.svg')) {
+ const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8' });
+ // We know that the contents are present, as we only emit this property for SVG files
+ return { code: makeSvgComponent(imageMetadata, contents) };
+ }
+
+ // We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build.
+ // Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported
+ // an image on the client, it should be present in the final build.
+ if (options?.ssr) {
+ return {
+ code: `export default ${getProxyCode(
+ imageMetadata,
+ settings.buildOutput === 'server',
+ )}`,
+ };
+ } else {
+ globalThis.astroAsset.referencedImages.add(imageMetadata.fsPath);
+ return {
+ code: `export default ${JSON.stringify(imageMetadata)}`,
+ };
+ }
+ }
+ },
+ },
+ fontsPlugin({ settings, sync, logger }),
+ ];
+}