astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
465 lines (464 loc) • 16.8 kB
JavaScript
import fsMod from "node:fs";
import { fileURLToPath } from "node:url";
import { time } from "@astrojs/internal-helpers/timings";
import colors from "piccolore";
import { mergeConfig as mergeViteConfig } from "vite";
import astroIntegrationActionsRouteHandler from "../actions/integration.js";
import { isActionsFilePresent } from "../actions/actions-file.js";
import { CONTENT_LAYER_TYPE } from "../content/consts.js";
import { globalContentLayer } from "../content/instance.js";
import { globalContentConfigObserver } from "../content/utils.js";
import { buildClientDirectiveEntrypoint } from "../core/client-directive/index.js";
import { mergeConfig } from "../core/config/merge.js";
import { validateConfigRefined } from "../core/config/validate.js";
import { validateSetAdapter } from "../core/dev/adapter-validation.js";
import { getRouteGenerator } from "../core/routing/generator.js";
import { validateSupportedFeatures } from "./features-validation.js";
import { loadLoggerDestination } from "../core/logger/load.js";
async function withTakingALongTimeMsg({ name, hookName, hookFn, logger, integrationLogger }) {
const timeout = setTimeout(() => {
logger.info("build", `Waiting for integration ${colors.bold(JSON.stringify(name))}, hook ${colors.bold(JSON.stringify(hookName))}...`);
}, 3e3);
try {
return await hookFn();
} catch (err) {
integrationLogger.error(`An unhandled error occurred while running the ${colors.bold(JSON.stringify(hookName))} hook`);
throw err;
} finally {
clearTimeout(timeout);
}
}
/** Executes the specified hook of the integration if present, and handles loggers */
async function runHookInternal({ integration, hookName, logger, params }) {
const hook = integration?.hooks?.[hookName];
const integrationLogger = getLogger(integration, logger);
if (hook) {
await time("integration", integration.name, () => withTakingALongTimeMsg({
name: integration.name,
hookName,
hookFn: () => hook(Object.assign(params(), { logger: integrationLogger })),
logger,
integrationLogger
}), { hook: hookName });
}
return { integrationLogger };
}
// Used internally to store instances of loggers.
const Loggers = new WeakMap();
function getLogger(integration, logger) {
if (Loggers.has(integration)) {
// SAFETY: we check the existence in the if block
return Loggers.get(integration);
}
const integrationLogger = logger.forkIntegrationLogger(integration.name);
Loggers.set(integration, integrationLogger);
return integrationLogger;
}
const serverEventPrefix = "astro-dev-toolbar";
export function getToolbarServerCommunicationHelpers(server) {
return {
/**
* Send a message to the dev toolbar that an app can listen for. The payload can be any serializable data.
* @param event - The event name
* @param payload - The payload to send
*/
send: (event, payload) => {
server.environments.client.hot.send(event, payload);
},
/**
* Receive a message from a dev toolbar app.
* @param event
* @param callback
*/
on: (event, callback) => {
server.hot.on(event, callback);
},
/**
* Fired when an app is initialized.
* @param appId - The id of the app that was initialized
* @param callback - The callback to run when the app is initialized
*/
onAppInitialized: (appId, callback) => {
server.hot.on(`${serverEventPrefix}:${appId}:initialized`, callback);
},
/**
* Fired when an app is toggled on or off.
* @param appId - The id of the app that was toggled
* @param callback - The callback to run when the app is toggled
*/
onAppToggled: (appId, callback) => {
server.hot.on(`${serverEventPrefix}:${appId}:toggled`, callback);
}
};
}
// Will match any invalid characters (will be converted to _). We only allow a-zA-Z0-9.-_
const SAFE_CHARS_RE = /[^\w.-]/g;
export function normalizeCodegenDir(integrationName) {
return `./integrations/${integrationName.replace(SAFE_CHARS_RE, "_")}/`;
}
export function normalizeInjectedTypeFilename(filename, integrationName) {
if (!filename.endsWith(".d.ts")) {
throw new Error(`Integration ${colors.bold(integrationName)} is injecting a type that does not end with "${colors.bold(".d.ts")}"`);
}
return `${normalizeCodegenDir(integrationName)}${filename.replace(SAFE_CHARS_RE, "_")}`;
}
export async function runHookConfigSetup({ settings, command, logger, isRestart = false, fs = fsMod }) {
// An adapter is an integration, so if one is provided add it to the list of integrations.
if (settings.config.adapter) {
settings.config.integrations.unshift(settings.config.adapter);
}
const actionsFilename = await isActionsFilePresent(fs, settings.config.srcDir);
if (actionsFilename) {
settings.config.integrations.push(astroIntegrationActionsRouteHandler({
settings,
filename: actionsFilename
}));
}
let updatedConfig = { ...settings.config };
let updatedSettings = {
...settings,
config: updatedConfig
};
let addedClientDirectives = new Map();
let astroJSXRenderer = null;
// eslint-disable-next-line @typescript-eslint/prefer-for-of -- We need a for loop to be able to read integrations pushed while the loop is running.
for (let i = 0; i < updatedConfig.integrations.length; i++) {
const integration = updatedConfig.integrations[i];
/**
* By making integration hooks optional, Astro can now ignore null or undefined Integrations
* instead of giving an internal error most people can't read
*
* This also enables optional integrations, e.g.
* ```ts
* integration: [
* // Only run `compress` integration in production environments, etc...
* import.meta.env.production ? compress() : null
* ]
* ```
*/
let isLoggerUpdated = false;
const { integrationLogger } = await runHookInternal({
integration,
hookName: "astro:config:setup",
logger,
params: () => {
const hooks = {
config: updatedConfig,
command,
isRestart,
addRenderer(renderer) {
if (!renderer.name) {
throw new Error(`Integration ${colors.bold(integration.name)} has an unnamed renderer.`);
}
if (!renderer.serverEntrypoint) {
throw new Error(`Renderer ${colors.bold(renderer.name)} does not provide a serverEntrypoint.`);
}
if (renderer.name === "astro:jsx") {
astroJSXRenderer = renderer;
} else {
updatedSettings.renderers.push(renderer);
}
},
injectScript: (stage, content) => {
updatedSettings.scripts.push({
stage,
content
});
},
updateConfig: (newConfig) => {
// Logger destination is updated later because it's async
if (newConfig.logger?.entrypoint) {
isLoggerUpdated = true;
}
updatedConfig = mergeConfig(updatedConfig, newConfig);
return { ...updatedConfig };
},
injectRoute: (injectRoute) => {
if (injectRoute.entrypoint == null && "entryPoint" in injectRoute) {
logger.warn(null, `The injected route "${injectRoute.pattern}" by ${integration.name} specifies the entry point with the "entryPoint" property. This property is deprecated, please use "entrypoint" instead.`);
injectRoute.entrypoint = injectRoute.entryPoint;
}
updatedSettings.injectedRoutes.push({
...injectRoute,
origin: "external"
});
},
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
},
addDevToolbarApp: (entrypoint) => {
updatedSettings.devToolbarApps.push(entrypoint);
},
addClientDirective: ({ name, entrypoint }) => {
if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) {
throw new Error(`The "${integration.name}" integration is trying to add the "${name}" client directive, but it already exists.`);
}
// TODO: this should be performed after astro:config:done
addedClientDirectives.set(name, buildClientDirectiveEntrypoint(name, entrypoint, settings.config.root));
},
addMiddleware: ({ order, entrypoint }) => {
if (typeof updatedSettings.middlewares[order] === "undefined") {
throw new Error(`The "${integration.name}" integration is trying to add middleware but did not specify an order.`);
}
logger.debug("middleware", `The integration ${integration.name} has added middleware that runs ${order === "pre" ? "before" : "after"} any application middleware you define.`);
updatedSettings.middlewares[order].push(typeof entrypoint === "string" ? entrypoint : fileURLToPath(entrypoint));
},
createCodegenDir: () => {
const codegenDir = new URL(normalizeCodegenDir(integration.name), settings.dotAstroDir);
fs.mkdirSync(codegenDir, { recursive: true });
return codegenDir;
}
};
// Public, intentionally undocumented hooks - not subject to semver.
// Intended for internal integrations (ex. `@astrojs/mdx`),
// though accessible to integration authors if discovered.
function addPageExtension(...input) {
const exts = input.flat(Number.POSITIVE_INFINITY).map((ext) => `.${ext.replace(/^\./, "")}`);
updatedSettings.pageExtensions.push(...exts);
}
function addContentEntryType(contentEntryType) {
updatedSettings.contentEntryTypes.push(contentEntryType);
}
function addDataEntryType(dataEntryType) {
updatedSettings.dataEntryTypes.push(dataEntryType);
}
Object.defineProperty(hooks, "addPageExtension", {
value: addPageExtension,
writable: false,
enumerable: false
});
Object.defineProperty(hooks, "addContentEntryType", {
value: addContentEntryType,
writable: false,
enumerable: false
});
Object.defineProperty(hooks, "addDataEntryType", {
value: addDataEntryType,
writable: false,
enumerable: false
});
return hooks;
}
});
// Add custom client directives to settings, waiting for compiled code by rolldown
for (const [name, compiled] of addedClientDirectives) {
updatedSettings.clientDirectives.set(name, await compiled);
}
try {
updatedConfig = await validateConfigRefined(updatedConfig);
if (isLoggerUpdated) {
logger.setDestination(await loadLoggerDestination(updatedConfig.logger, updatedConfig.root));
}
} catch (error) {
integrationLogger.error("An error occurred while updating the config");
throw error;
}
}
// The astro:jsx renderer should come last, to not interfere with others.
if (astroJSXRenderer) {
updatedSettings.renderers.push(astroJSXRenderer);
}
updatedSettings.config = updatedConfig;
return updatedSettings;
}
export async function runHookConfigDone({ settings, logger, command }) {
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: "astro:config:done",
logger,
params: () => ({
config: settings.config,
setAdapter(adapter) {
validateSetAdapter(logger, settings, adapter, integration.name, command);
if (adapter.adapterFeatures?.buildOutput !== "static") {
settings.buildOutput = "server";
}
if (!adapter.supportedAstroFeatures) {
throw new Error(`The adapter ${adapter.name} doesn't provide a feature map. It is required in Astro 4.0.`);
} else {
validateSupportedFeatures(adapter.name, adapter.supportedAstroFeatures, settings, logger);
}
settings.adapter = adapter;
},
injectTypes(injectedType) {
const normalizedFilename = normalizeInjectedTypeFilename(injectedType.filename, integration.name);
settings.injectedTypes.push({
filename: normalizedFilename,
content: injectedType.content
});
// It must be relative to dotAstroDir here and not inside normalizeInjectedTypeFilename
// because injectedTypes are handled relatively to the dotAstroDir already
return new URL(normalizedFilename, settings.dotAstroDir);
},
get buildOutput() {
return settings.buildOutput;
}
})
});
}
}
export async function runHookServerSetup({ config, server, logger }) {
let refreshContent;
refreshContent = async (options) => {
const contentConfig = globalContentConfigObserver.get();
if (contentConfig.status !== "loaded" || !Object.values(contentConfig.config.collections).some((collection) => collection.type === CONTENT_LAYER_TYPE)) {
return;
}
const contentLayer = await globalContentLayer.get();
await contentLayer?.sync(options);
};
for (const integration of config.integrations) {
await runHookInternal({
integration,
hookName: "astro:server:setup",
logger,
params: () => ({
server,
toolbar: getToolbarServerCommunicationHelpers(server),
refreshContent
})
});
}
}
export async function runHookServerStart({ config, address, logger }) {
for (const integration of config.integrations) {
await runHookInternal({
integration,
hookName: "astro:server:start",
logger,
params: () => ({ address })
});
}
}
export async function runHookServerDone({ config, logger }) {
for (const integration of config.integrations) {
await runHookInternal({
integration,
hookName: "astro:server:done",
logger,
params: () => ({})
});
}
}
export async function runHookBuildStart({ settings, logger }) {
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: "astro:build:start",
logger,
params: () => ({ setPrerenderer(prerenderer) {
settings.prerenderer = prerenderer;
} })
});
}
}
export async function runHookBuildSetup({ config, vite, pages, target, logger }) {
let updatedConfig = vite;
for (const integration of config.integrations) {
await runHookInternal({
integration,
hookName: "astro:build:setup",
logger,
params: () => ({
vite,
pages,
target,
updateConfig: (newConfig) => {
updatedConfig = mergeViteConfig(updatedConfig, newConfig);
return { ...updatedConfig };
}
})
});
}
return updatedConfig;
}
export async function runHookBuildSsr({ config, manifest, logger, middlewareEntryPoint }) {
for (const integration of config.integrations) {
await runHookInternal({
integration,
hookName: "astro:build:ssr",
logger,
params: () => ({
manifest,
middlewareEntryPoint
})
});
}
}
export async function runHookBuildGenerated({ settings, logger, routeToHeaders, dir }) {
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: "astro:build:generated",
logger,
params: () => ({
dir,
routeToHeaders
})
});
}
}
export async function runHookBuildDone({ settings, dir, pages, routes, logger }) {
await fsMod.promises.mkdir(dir, { recursive: true });
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: "astro:build:done",
logger,
params: () => ({
pages: pages.map((p) => ({ pathname: p })),
dir,
assets: new Map(routes.filter((r) => r.distURL !== undefined).map((r) => [r.route, r.distURL]))
})
});
}
}
export async function runHookRouteSetup({ route, settings, logger }) {
const prerenderChangeLogs = [];
for (const integration of settings.config.integrations) {
const originalRoute = { ...route };
await runHookInternal({
integration,
hookName: "astro:route:setup",
logger,
params: () => ({ route })
});
if (route.prerender !== originalRoute.prerender) {
prerenderChangeLogs.push({
integrationName: integration.name,
value: route.prerender
});
}
}
if (prerenderChangeLogs.length > 1) {
logger.debug("router", `The ${route.component} route's prerender option has been changed multiple times by integrations:\n` + prerenderChangeLogs.map((log) => `- ${log.integrationName}: ${log.value}`).join("\n"));
}
}
export async function runHookRoutesResolved({ routes, settings, logger }) {
for (const integration of settings.config.integrations) {
await runHookInternal({
integration,
hookName: "astro:routes:resolved",
logger,
params: () => ({ routes: routes.map((route) => toIntegrationResolvedRoute(route, settings.config.trailingSlash)) })
});
}
}
export function toIntegrationResolvedRoute(route, trailingSlash) {
return {
isPrerendered: route.prerender,
entrypoint: route.component,
pattern: route.route,
params: route.params,
origin: route.origin,
generate: getRouteGenerator(route.segments, trailingSlash),
patternRegex: route.pattern,
segments: route.segments,
type: route.type,
pathname: route.pathname,
redirect: route.redirect,
redirectRoute: route.redirectRoute ? toIntegrationResolvedRoute(route.redirectRoute, trailingSlash) : undefined,
fallbackRoutes: route.fallbackRoutes.map((fallbackRoute) => toIntegrationResolvedRoute(fallbackRoute, trailingSlash))
};
}