UNPKG

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
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)); }; })()`; }