astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
417 lines (416 loc) • 16 kB
JavaScript
import fs from "node:fs";
import os from "node:os";
import { bgGreen, black, blue, bold, dim, green, magenta, red, yellow } from "kleur/colors";
import PLimit from "p-limit";
import PQueue from "p-queue";
import { NOOP_ACTIONS_MOD } from "../../actions/noop-actions.js";
import {
generateImagesForPath,
getStaticImageList,
prepareAssetsGenerationEnv
} from "../../assets/build/generate.js";
import { hasPrerenderedPages } from "../../core/build/internal.js";
import {
isRelativePath,
joinPaths,
removeLeadingForwardSlash,
removeTrailingForwardSlash
} from "../../core/path.js";
import { toFallbackType, toRoutingStrategy } from "../../i18n/utils.js";
import { runHookBuildGenerated } from "../../integrations/hooks.js";
import { getServerOutputDirectory } from "../../prerender/utils.js";
import { NoPrerenderedRoutesWithDomains } from "../errors/errors-data.js";
import { AstroError, AstroErrorData } from "../errors/index.js";
import { NOOP_MIDDLEWARE_FN } from "../middleware/noop-middleware.js";
import { getRedirectLocationOrThrow, routeIsRedirect } from "../redirects/index.js";
import { RenderContext } from "../render-context.js";
import { callGetStaticPaths } from "../render/route-cache.js";
import { createRequest } from "../request.js";
import { redirectTemplate } from "../routing/3xx.js";
import { matchRoute } from "../routing/match.js";
import { stringifyParams } from "../routing/params.js";
import { getOutputFilename } from "../util.js";
import { getOutFile, getOutFolder } from "./common.js";
import { cssOrder, mergeInlineCss } from "./internal.js";
import { BuildPipeline } from "./pipeline.js";
import { getTimeStat, shouldAppendForwardSlash } from "./util.js";
async function generatePages(options, internals) {
const generatePagesTimer = performance.now();
const ssr = options.settings.buildOutput === "server";
let manifest;
if (ssr) {
manifest = await BuildPipeline.retrieveManifest(options.settings, internals);
} else {
const baseDirectory = getServerOutputDirectory(options.settings);
const renderersEntryUrl = new URL("renderers.mjs", baseDirectory);
const renderers = await import(renderersEntryUrl.toString());
const middleware = internals.middlewareEntryPoint ? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest) : NOOP_MIDDLEWARE_FN;
const actions = internals.astroActionsEntryPoint ? await import(internals.astroActionsEntryPoint.toString()).then((mod) => mod) : NOOP_ACTIONS_MOD;
manifest = createBuildManifest(
options.settings,
internals,
renderers.renderers,
middleware,
actions,
options.key
);
}
const pipeline = BuildPipeline.create({ internals, manifest, options });
const { config, logger } = pipeline;
if (ssr && !hasPrerenderedPages(internals)) {
delete globalThis?.astroAsset?.addStaticImage;
}
const verb = ssr ? "prerendering" : "generating";
logger.info("SKIP_FORMAT", `
${bgGreen(black(` ${verb} static routes `))}`);
const builtPaths = /* @__PURE__ */ new Set();
const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
if (ssr) {
for (const [pageData, filePath] of pagesToGenerate) {
if (pageData.route.prerender) {
if (config.i18n?.domains && Object.keys(config.i18n.domains).length > 0) {
throw new AstroError({
...NoPrerenderedRoutesWithDomains,
message: NoPrerenderedRoutesWithDomains.message(pageData.component)
});
}
const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath);
const ssrEntry = ssrEntryPage;
await generatePage(pageData, ssrEntry, builtPaths, pipeline);
}
}
} else {
for (const [pageData, filePath] of pagesToGenerate) {
const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath);
await generatePage(pageData, entry, builtPaths, pipeline);
}
}
logger.info(
null,
green(`\u2713 Completed in ${getTimeStat(generatePagesTimer, performance.now())}.
`)
);
const staticImageList = getStaticImageList();
if (staticImageList.size) {
logger.info("SKIP_FORMAT", `${bgGreen(black(` generating optimized images `))}`);
const totalCount = Array.from(staticImageList.values()).map((x) => x.transforms.size).reduce((a, b) => a + b, 0);
const cpuCount = os.cpus().length;
const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount);
const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) });
const assetsTimer = performance.now();
for (const [originalPath, transforms] of staticImageList) {
queue.add(() => generateImagesForPath(originalPath, transforms, assetsCreationPipeline)).catch((e) => {
throw e;
});
}
await queue.onIdle();
const assetsTimeEnd = performance.now();
logger.info(null, green(`\u2713 Completed in ${getTimeStat(assetsTimer, assetsTimeEnd)}.
`));
delete globalThis?.astroAsset?.addStaticImage;
}
await runHookBuildGenerated({ settings: options.settings, logger });
}
const THRESHOLD_SLOW_RENDER_TIME_MS = 500;
async function generatePage(pageData, ssrEntry, builtPaths, pipeline) {
const { config, logger } = pipeline;
const pageModulePromise = ssrEntry.page;
const styles = pageData.styles.sort(cssOrder).map(({ sheet }) => sheet).reduce(mergeInlineCss, []);
const linkIds = [];
if (!pageModulePromise) {
throw new Error(
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`
);
}
const pageModule = await pageModulePromise();
const generationOptions = {
pageData,
linkIds,
scripts: null,
styles,
mod: pageModule
};
async function generatePathWithLogs(path, route, index, paths, isConcurrent) {
const timeStart = performance.now();
pipeline.logger.debug("build", `Generating: ${path}`);
const filePath = getOutputFilename(config, path, pageData.route);
const lineIcon = index === paths.length - 1 && !isConcurrent || paths.length === 1 ? "\u2514\u2500" : "\u251C\u2500";
if (!isConcurrent) {
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
}
const created = await generatePath(path, pipeline, generationOptions, route);
const timeEnd = performance.now();
const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS;
const timeIncrease = (isSlow ? red : dim)(`(+${getTimeStat(timeStart, timeEnd)})`);
const notCreated = created === false ? yellow("(file not created, response body was empty)") : "";
if (isConcurrent) {
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)} ${timeIncrease} ${notCreated}`);
} else {
logger.info("SKIP_FORMAT", ` ${timeIncrease} ${notCreated}`);
}
}
for (const route of eachRouteInRouteData(pageData)) {
const icon = route.type === "page" || route.type === "redirect" || route.type === "fallback" ? green("\u25B6") : magenta("\u03BB");
logger.info(null, `${icon} ${getPrettyRouteName(route)}`);
const paths = await getPathsForRoute(route, pageModule, pipeline, builtPaths);
if (config.build.concurrency > 1) {
const limit = PLimit(config.build.concurrency);
const promises = [];
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
promises.push(limit(() => generatePathWithLogs(path, route, i, paths, true)));
}
await Promise.allSettled(promises);
} else {
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
await generatePathWithLogs(path, route, i, paths, false);
}
}
}
}
function* eachRouteInRouteData(data) {
yield data.route;
for (const fallbackRoute of data.route.fallbackRoutes) {
yield fallbackRoute;
}
}
async function getPathsForRoute(route, mod, pipeline, builtPaths) {
const { logger, options, routeCache, serverLike, config } = pipeline;
let paths = [];
if (route.pathname) {
paths.push(route.pathname);
builtPaths.add(removeTrailingForwardSlash(route.pathname));
} else {
const staticPaths = await callGetStaticPaths({
mod,
route,
routeCache,
logger,
ssr: serverLike,
base: config.base
}).catch((err) => {
logger.error("build", `Failed to call getStaticPaths for ${route.component}`);
throw err;
});
const label = staticPaths.length === 1 ? "page" : "pages";
logger.debug(
"build",
`\u251C\u2500\u2500 ${bold(green("\u221A"))} ${route.component} \u2192 ${magenta(`[${staticPaths.length} ${label}]`)}`
);
paths = staticPaths.map((staticPath) => {
try {
return stringifyParams(staticPath.params, route);
} catch (e) {
if (e instanceof TypeError) {
throw getInvalidRouteSegmentError(e, route, staticPath);
}
throw e;
}
}).filter((staticPath) => {
if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) {
return true;
}
const matchedRoute = matchRoute(decodeURI(staticPath), options.routesList);
return matchedRoute === route;
});
for (const staticPath of paths) {
builtPaths.add(removeTrailingForwardSlash(staticPath));
}
}
return paths;
}
function getInvalidRouteSegmentError(e, route, staticPath) {
const invalidParam = /^Expected "([^"]+)"/.exec(e.message)?.[1];
const received = invalidParam ? staticPath.params[invalidParam] : void 0;
let hint = "Learn about dynamic routes at https://docs.astro.build/en/core-concepts/routing/#dynamic-routes";
if (invalidParam && typeof received === "string") {
const matchingSegment = route.segments.find(
(segment) => segment[0]?.content === invalidParam
)?.[0];
const mightBeMissingSpread = matchingSegment?.dynamic && !matchingSegment?.spread;
if (mightBeMissingSpread) {
hint = `If the param contains slashes, try using a rest parameter: **[...${invalidParam}]**. Learn more at https://docs.astro.build/en/core-concepts/routing/#dynamic-routes`;
}
}
return new AstroError({
...AstroErrorData.InvalidDynamicRoute,
message: invalidParam ? AstroErrorData.InvalidDynamicRoute.message(
route.route,
JSON.stringify(invalidParam),
JSON.stringify(received)
) : `Generated path for ${route.route} is invalid.`,
hint
});
}
function addPageName(pathname, opts) {
const trailingSlash = opts.settings.config.trailingSlash;
const buildFormat = opts.settings.config.build.format;
const pageName = shouldAppendForwardSlash(trailingSlash, buildFormat) ? pathname.replace(/\/?$/, "/").replace(/^\//, "") : pathname.replace(/^\//, "");
opts.pageNames.push(pageName);
}
function getUrlForPath(pathname, base, origin, format, trailingSlash, routeType) {
let ending;
switch (format) {
case "directory":
case "preserve": {
ending = trailingSlash === "never" ? "" : "/";
break;
}
case "file":
default: {
ending = ".html";
break;
}
}
let buildPathname;
if (pathname === "/" || pathname === "") {
buildPathname = base;
} else if (routeType === "endpoint") {
const buildPathRelative = removeLeadingForwardSlash(pathname);
buildPathname = joinPaths(base, buildPathRelative);
} else {
const buildPathRelative = removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending;
buildPathname = joinPaths(base, buildPathRelative);
}
return new URL(buildPathname, origin);
}
async function generatePath(pathname, pipeline, gopts, route) {
const { mod } = gopts;
const { config, logger, options } = pipeline;
logger.debug("build", `Generating: ${pathname}`);
if (route.type === "page") {
addPageName(pathname, options);
}
if (route.type === "fallback" && // If route is index page, continue rendering. The index page should
// always be rendered
route.pathname !== "/" && // Check if there is a translated page with the same path
Object.values(options.allPages).some((val) => val.route.pattern.test(pathname))) {
return void 0;
}
const url = getUrlForPath(
pathname,
config.base,
options.origin,
config.build.format,
config.trailingSlash,
route.type
);
const request = createRequest({
url,
headers: new Headers(),
logger,
isPrerendered: true,
routePattern: route.component
});
const renderContext = await RenderContext.create({
pipeline,
pathname,
request,
routeData: route,
clientAddress: void 0
});
let body;
let response;
try {
response = await renderContext.render(mod);
} catch (err) {
if (!AstroError.is(err) && !err.id && typeof err === "object") {
err.id = route.component;
}
throw err;
}
if (response.status >= 300 && response.status < 400) {
if (routeIsRedirect(route) && !config.build.redirects) {
return void 0;
}
const locationSite = getRedirectLocationOrThrow(response.headers);
const siteURL = config.site;
const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
const fromPath = new URL(request.url).pathname;
body = redirectTemplate({
status: response.status,
absoluteLocation: location,
relativeLocation: locationSite,
from: fromPath
});
if (config.compressHTML === true) {
body = body.replaceAll("\n", "");
}
if (route.type !== "redirect") {
route.redirect = location.toString();
}
} else {
if (!response.body) return false;
body = Buffer.from(await response.arrayBuffer());
}
const encodedPath = encodeURI(pathname);
const outFolder = getOutFolder(pipeline.settings, encodedPath, route);
const outFile = getOutFile(config, outFolder, encodedPath, route);
if (route.distURL) {
route.distURL.push(outFile);
} else {
route.distURL = [outFile];
}
await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, body);
return true;
}
function getPrettyRouteName(route) {
if (isRelativePath(route.component)) {
return route.route;
}
if (route.component.includes("node_modules/")) {
return /.*node_modules\/(.+)/.exec(route.component)?.[1] ?? route.component;
}
return route.component;
}
function createBuildManifest(settings, internals, renderers, middleware, actions, key) {
let i18nManifest = void 0;
if (settings.config.i18n) {
i18nManifest = {
fallback: settings.config.i18n.fallback,
fallbackType: toFallbackType(settings.config.i18n.routing),
strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
defaultLocale: settings.config.i18n.defaultLocale,
locales: settings.config.i18n.locales,
domainLookupTable: {}
};
}
return {
hrefRoot: settings.config.root.toString(),
srcDir: settings.config.srcDir,
buildClientDir: settings.config.build.client,
buildServerDir: settings.config.build.server,
publicDir: settings.config.publicDir,
outDir: settings.config.outDir,
cacheDir: settings.config.cacheDir,
trailingSlash: settings.config.trailingSlash,
assets: /* @__PURE__ */ new Set(),
entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
inlinedScripts: internals.inlinedScripts,
routes: [],
adapterName: "",
clientDirectives: settings.clientDirectives,
compressHTML: settings.config.compressHTML,
renderers,
base: settings.config.base,
userAssetsBase: settings.config?.vite?.base,
assetsPrefix: settings.config.build.assetsPrefix,
site: settings.config.site,
componentMetadata: internals.componentMetadata,
i18n: i18nManifest,
buildFormat: settings.config.build.format,
middleware() {
return {
onRequest: middleware
};
},
actions: () => actions,
checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === "server") ?? false,
key
};
}
export {
generatePages
};