astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
344 lines (337 loc) • 16.1 kB
JavaScript
import { extname } from "node:path";
import MagicString from "magic-string";
import picomatch from "picomatch";
import { pathToDirectoryURL } from "../core/build/common.js";
import { AstroError, AstroErrorData } from "../core/errors/index.js";
import { appendForwardSlash, removeQueryString } from "../core/path.js";
import { normalizePath } from "../core/viteUtils.js";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js";
import { isAstroServerEnvironment } from "../environments.js";
import { RESOLVED_VIRTUAL_GET_IMAGE_ID, RESOLVED_VIRTUAL_IMAGE_STYLES_ID, RESOLVED_VIRTUAL_MODULE_ID, VALID_INPUT_FORMATS, VIRTUAL_GET_IMAGE_ID, VIRTUAL_IMAGE_STYLES_ID, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID, IMAGE_SERVICE_ENVIRONMENT_NAME } from "./consts.js";
import { RUNTIME_VIRTUAL_MODULE_ID } from "./fonts/constants.js";
import { fontsPlugin } from "./fonts/vite-plugin-fonts.js";
import { getAssetsPrefix } from "./utils/getAssetsPrefix.js";
import { ASSETS_ESM_PLUGIN_NAME, emitClientAsset } from "./utils/assets.js";
import { emitImageMetadata } from "./utils/node.js";
import { CONTENT_IMAGE_FLAG } from "../content/consts.js";
import { getImageAssetModule } from "./utils/image-asset-code.js";
import { getImageServiceConfig } from "./utils/service-config.js";
import { makeSvgComponent, parseSvgComponentData } from "./svg/utils.js";
const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join("|")})`, "i");
const assetRegexEnds = new RegExp(`\\.(${VALID_INPUT_FORMATS.join("|")})$`, "i");
/**
* Emitted into the `astro:assets` virtual modules: the `AstroRuntimeLogger` handed to the
* image service hooks called by `getImage()` and `inferRemoteSize()`.
*
* The logger is built from `virtual:astro:logger` rather than the manifest, for the same
* reason `astro:content` does it (see `templates/content/module.mjs`): the logger module is
* a leaf, so reaching the user's configured destination through it cannot drag every
* renderer and page into the importer's module graph.
*/
const RUNTIME_LOGGER_SETUP = `
import { createConsoleLogger as _createConsoleLogger } from "astro/logger/console";
import _loggerDestination, { level as _loggerLevel } from "virtual:astro:logger";
const _astroLogger = _createConsoleLogger({ level: _loggerLevel });
if (_loggerDestination) _astroLogger.setDestination(_loggerDestination);
const _runtimeLogger = {
info: (message) => _astroLogger.info(null, message),
warn: (message) => _astroLogger.warn(null, message),
error: (message) => _astroLogger.error(null, message),
};
`;
/**
* The client build has no configured destination to reach — and pulling the logger
* implementation into it just to call `inferRemoteSize()` would be dead weight — so the
* browser gets a console shim instead.
*/
const CLIENT_RUNTIME_LOGGER_SETUP = `
const _runtimeLogger = {
info: (message) => console.info(message),
warn: (message) => console.warn(message),
error: (message) => console.error(message),
};
`;
function isServerEnvironment(environment) {
return isAstroServerEnvironment(environment) || environment.name === IMAGE_SERVICE_ENVIRONMENT_NAME;
}
// Dev uses the `build` service everywhere, as it's the one that can transform images locally.
function getImageServiceTarget(environment) {
if (environment.config.command === "serve") return "build";
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender || environment.name === IMAGE_SERVICE_ENVIRONMENT_NAME ? "build" : "runtime";
}
// Extras are non-enumerable so image services that serialize the config never see them.
function getImageConfigCode(settings, environment) {
const assetQueryParams = settings.adapter?.client?.assetQueryParams ? `new URLSearchParams(${JSON.stringify(Array.from(settings.adapter.client.assetQueryParams.entries()))})` : "undefined";
const staticImageConfig = {
base: settings.config.base,
assetsPrefix: settings.config.build.assetsPrefix,
assetsDir: settings.config.build.assets
};
return `
export const imageConfig = ${JSON.stringify({
...settings.config.image,
service: getImageServiceConfig(settings.config.image.service, getImageServiceTarget(environment))
})};
Object.defineProperties(imageConfig, {
assetQueryParams: { value: ${assetQueryParams}, enumerable: false, configurable: true },
staticImageConfig: {
value: ${JSON.stringify(staticImageConfig)},
enumerable: false,
configurable: true,
},
});
`;
}
export default function assets({ fs, settings, sync, logger }) {
let resolvedConfig;
let shouldEmitFile = false;
const referencedImages = new Set();
const api = {
markReferenced(fsPath) {
referencedImages.add(fsPath);
},
referencedImages
};
const imageComponentPrefix = settings.config.image.responsiveStyles ? "Responsive" : "";
return [
(
// Expose the components and different utilities from `astro:assets`
{
name: "astro:assets",
resolveId: {
filter: { id: new RegExp(`^(${VIRTUAL_SERVICE_ID}|${VIRTUAL_MODULE_ID}|${VIRTUAL_GET_IMAGE_ID})$`) },
async handler(id) {
if (id === VIRTUAL_SERVICE_ID) {
if (isServerEnvironment(this.environment)) {
const target = getImageServiceTarget(this.environment);
return await this.resolve(getImageServiceConfig(settings.config.image.service, target).entrypoint);
}
return await this.resolve("astro/assets/services/noop");
}
if (id === VIRTUAL_MODULE_ID) {
return RESOLVED_VIRTUAL_MODULE_ID;
}
if (id === VIRTUAL_GET_IMAGE_ID) {
return RESOLVED_VIRTUAL_GET_IMAGE_ID;
}
}
},
load: {
filter: { id: new RegExp(`^(${RESOLVED_VIRTUAL_MODULE_ID}|${RESOLVED_VIRTUAL_GET_IMAGE_ID})$`) },
handler(id) {
if (id === RESOLVED_VIRTUAL_GET_IMAGE_ID) {
// Lightweight module exporting only getImage + imageConfig.
// No component references (Image, Picture, Font) to avoid TDZ
// errors when the content runtime and component pages are
// bundled into the same prerender chunk (see #16036).
const isServer = isServerEnvironment(this.environment);
const getImageExport = isServer ? `${RUNTIME_LOGGER_SETUP}
import { getImage as getImageInternal } from "astro/assets";
export const getImage = async (options) => await getImageInternal(options, imageConfig, _runtimeLogger);` : `import { AstroError, AstroErrorData } from "astro/errors";
export const getImage = async () => {
throw new AstroError(
AstroErrorData.GetImageNotUsedOnServer.message,
AstroErrorData.GetImageNotUsedOnServer.hint,
);
};`;
return { code: `
${getImageConfigCode(settings, this.environment)}
${getImageExport}
` };
}
const isServer = isServerEnvironment(this.environment);
const getImageExport = isServer ? `import { getImage as getImageInternal } from "astro/assets";
export const getImage = async (options) => await getImageInternal(options, imageConfig, _runtimeLogger);` : `import { AstroError, AstroErrorData } from "astro/errors";
export const getImage = async () => {
throw new AstroError(
AstroErrorData.GetImageNotUsedOnServer.message,
AstroErrorData.GetImageNotUsedOnServer.hint,
);
};`;
return { code: `
${isServer ? RUNTIME_LOGGER_SETUP : CLIENT_RUNTIME_LOGGER_SETUP}
import { getConfiguredImageService as _getConfiguredImageService } from "astro/assets";
export { isLocalService } from "astro/assets";
${settings.config.image.responsiveStyles ? `import "${VIRTUAL_IMAGE_STYLES_ID}";` : ""}
export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro";
export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro";
import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js";
export { default as Font } from "astro/components/Font.astro";
export * from "${RUNTIME_VIRTUAL_MODULE_ID}";
export const getConfiguredImageService = _getConfiguredImageService;
export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})};
export const safeModulePaths = new Set(${JSON.stringify(
// @ts-expect-error safeModulePaths is internal to Vite
Array.from(resolvedConfig.safeModulePaths ?? [])
)});
export const fsDenyGlob = ${serializeFsDenyGlob(resolvedConfig.server.fs?.deny ?? [])};
${getImageConfigCode(settings, this.environment)}
export const inferRemoteSize = async (url) => {
const service = await _getConfiguredImageService();
return service.getRemoteSize?.(url, imageConfig, _runtimeLogger) ?? inferRemoteSizeInternal(url, imageConfig);
}
// 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(pathToDirectoryURL(resolvedConfig.environments.client.build.outDir))});
export const serverDir = /* #__PURE__ */ new URL(${JSON.stringify(pathToDirectoryURL(resolvedConfig.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr].build.outDir))});
${getImageExport}
` };
}
},
// 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: ASSETS_ESM_PLUGIN_NAME,
enforce: "pre",
api,
config(_, env) {
shouldEmitFile = env.command === "build";
},
configResolved(viteConfig) {
resolvedConfig = viteConfig;
},
load: {
filter: { id: assetRegex },
async handler(id) {
// Content collection images have the astroContentImageFlag query param.
// Strip it so we can process the image, but remember it so we can avoid
// creating SVG components (which import from the server runtime and cause
// circular dependency deadlocks with top-level await).
const isContentImage = id.includes(CONTENT_IMAGE_FLAG);
if (isContentImage) {
id = removeQueryString(id);
}
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
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 fileEmitter = shouldEmitFile ? (opts) => emitClientAsset(this, opts) : undefined;
const imageMetadata = await emitImageMetadata(id, fileEmitter);
if (!imageMetadata) {
throw new AstroError({
...AstroErrorData.ImageNotFound,
message: AstroErrorData.ImageNotFound.message(id)
});
}
// 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 (isServerEnvironment(this.environment)) {
// For SVGs imported directly (not via content collections), create a full
// component that can be rendered inline. For content collection SVGs, the
// component is reconstructed later in content/runtime.ts from __svgData
// embedded in the metadata, avoiding a server-runtime import here that
// would create a circular dependency when combined with TLA.
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: await makeSvgComponent(imageMetadata, contents, settings.config.experimental.svgOptimizer) };
}
// In SSR builds, any image loaded by the SSR environment could be reachable at
// request time without us knowing, so we'll always consider them as referenced.
const isSSROnlyEnvironment = settings.buildOutput === "server" && this.environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
if (isSSROnlyEnvironment) {
referencedImages.add(imageMetadata.fsPath);
}
// Content-collection SVG: embed parsed SVG data so content/runtime.ts can
// reconstruct a renderable component without importing from the server runtime
// (which would recreate the TLA circular-dependency deadlock, see #15575).
if (id.endsWith(".svg") && isContentImage) {
const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: "utf8" });
const svgData = await parseSvgComponentData(imageMetadata, contents, settings.config.experimental.svgOptimizer);
const metadataWithSvg = {
...imageMetadata,
__svgData: svgData
};
return { code: getImageAssetModule(metadataWithSvg, imageMetadata.fsPath, !isSSROnlyEnvironment) };
}
return { code: getImageAssetModule(imageMetadata, imageMetadata.fsPath, !isSSROnlyEnvironment) };
} else {
referencedImages.add(imageMetadata.fsPath);
return { code: `export default ${JSON.stringify(imageMetadata)}` };
}
}
}
}),
fontsPlugin({
settings,
sync,
logger
}),
{
name: "astro:image-styles",
resolveId: {
filter: { id: new RegExp(`^${VIRTUAL_IMAGE_STYLES_ID}$`) },
handler(id) {
if (id === VIRTUAL_IMAGE_STYLES_ID) {
return RESOLVED_VIRTUAL_IMAGE_STYLES_ID;
}
}
},
load: {
filter: { id: new RegExp(`^${RESOLVED_VIRTUAL_IMAGE_STYLES_ID}$`) },
async handler(id) {
if (id === RESOLVED_VIRTUAL_IMAGE_STYLES_ID) {
const { generateImageStylesCSS } = await import("./utils/generateImageStylesCSS.js");
const css = generateImageStylesCSS(settings.config.image.objectFit, settings.config.image.objectPosition);
return { code: css };
}
}
}
}
];
}
// Precompile the denies patterns to avoid using a CJS package in the runtime
function serializeFsDenyGlob(denyPatterns) {
// Replicate the same pattern transformation that Vite does internally:
// https://github.com/vitejs/vite/blob/e6156f71f0e21f4068941b63bcc17b0e9b0a7455/packages/vite/src/node/config.ts#L1931
const expandedPatterns = denyPatterns.map((pattern) => pattern.includes("/") ? pattern : `**/${pattern}`);
const regexes = expandedPatterns.map((pattern) => picomatch.makeRe(pattern, {
matchBase: false,
nocase: true,
dot: true
}));
// Serialize the regexes as a function that tests a path against all patterns
const serializedRegexes = regexes.map((re) => re.toString()).join(", ");
return `(function() {
const regexes = [${serializedRegexes}];
return function fsDenyGlob(testPath) {
return regexes.some(re => re.test(testPath));
};
})()`;
}