diff --git a/packages/astro/src/core/app/dev/pipeline.ts b/packages/astro/src/core/app/dev/pipeline.ts index 3335f969df48..ab23ade4ca5c 100644 --- a/packages/astro/src/core/app/dev/pipeline.ts +++ b/packages/astro/src/core/app/dev/pipeline.ts @@ -43,11 +43,12 @@ export class DevPipeline extends Pipeline { } async headElements(routeData: RouteData): Promise { + const { assetsPrefix, base } = this.manifest; const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData); // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. const links = new Set(); const scripts = new Set(); - const styles = createStylesheetElementSet(routeInfo?.styles ?? []); + const styles = createStylesheetElementSet(routeInfo?.styles ?? [], base, assetsPrefix); for (const script of routeInfo?.scripts ?? []) { if ('stage' in script) { diff --git a/packages/astro/src/core/app/pipeline.ts b/packages/astro/src/core/app/pipeline.ts index 9114ed36268c..cbe7fd782595 100644 --- a/packages/astro/src/core/app/pipeline.ts +++ b/packages/astro/src/core/app/pipeline.ts @@ -47,11 +47,12 @@ export class AppPipeline extends Pipeline { } async headElements(routeData: RouteData): Promise { + const { assetsPrefix, base } = this.manifest; const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData); // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. const links = new Set(); const scripts = new Set(); - const styles = createStylesheetElementSet(routeInfo?.styles ?? []); + const styles = createStylesheetElementSet(routeInfo?.styles ?? [], base, assetsPrefix); for (const script of routeInfo?.scripts ?? []) { if ('stage' in script) { @@ -62,7 +63,7 @@ export class AppPipeline extends Pipeline { }); } } else { - scripts.add(createModuleScriptElement(script)); + scripts.add(createModuleScriptElement(script, base, assetsPrefix)); } } return { links, styles, scripts }; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 0c319abfea04..5175ac5e9564 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -34,7 +34,7 @@ import { getOutputFilename } from '../util.js'; import { getOutFile, getOutFolder } from './common.js'; import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import { BuildPipeline } from './pipeline.js'; -import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js'; +import type { SinglePageBuiltModule, StaticBuildOptions } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; export async function generatePages( @@ -45,7 +45,13 @@ export async function generatePages( const generatePagesTimer = performance.now(); const ssr = options.settings.buildOutput === 'server'; // Import from the single prerender entrypoint - const prerenderEntryUrl = new URL('prerender-entry.mjs', prerenderOutputDir); + const prerenderEntryFileName = internals.prerenderEntryFileName; + if (!prerenderEntryFileName) { + throw new Error( + `Prerender entry filename not found in build internals. This is likely a bug in Astro.` + ); + } + const prerenderEntryUrl = new URL(prerenderEntryFileName, prerenderOutputDir); const prerenderEntry = await import(prerenderEntryUrl.toString()); // Grab the manifest and create the pipeline @@ -69,22 +75,22 @@ export async function generatePages( const pageMap = prerenderEntry.pageMap as Map Promise>; if (ssr) { - for (const [pageData, _] of pagesToGenerate) { - if (pageData.route.prerender) { + for (const [routeData, _] of pagesToGenerate) { + if (routeData.prerender) { // i18n domains won't work with pre rendered routes at the moment, so we need to throw an error if (config.i18n?.domains && Object.keys(config.i18n.domains).length > 0) { throw new AstroError({ ...NoPrerenderedRoutesWithDomains, - message: NoPrerenderedRoutesWithDomains.message(pageData.component), + message: NoPrerenderedRoutesWithDomains.message(routeData.component), }); } - await generatePage(app, pageMap, pageData, builtPaths, pipeline, routeToHeaders); + await generatePage(app, pageMap, routeData, builtPaths, pipeline, routeToHeaders); } } } else { - for (const [pageData, _] of pagesToGenerate) { - await generatePage(app, pageMap, pageData, builtPaths, pipeline, routeToHeaders); + for (const [routeData, _] of pagesToGenerate) { + await generatePage(app, pageMap, routeData, builtPaths, pipeline, routeToHeaders); } } logger.info( @@ -193,7 +199,7 @@ const THRESHOLD_SLOW_RENDER_TIME_MS = 500; async function generatePage( app: BaseApp, pageMap: Map Promise>, - pageData: PageBuildData, + routeData: RouteData, builtPaths: Set, pipeline: BuildPipeline, routeToHeaders: RouteToHeaders, @@ -212,7 +218,7 @@ async function generatePage( const timeStart = performance.now(); pipeline.logger.debug('build', `Generating: ${path}`); - const filePath = getOutputFilename(config, path, pageData.route); + const filePath = getOutputFilename(config, path, routeData); const lineIcon = (index === paths.length - 1 && !isConcurrent) || paths.length === 1 ? '└─' : '├─'; @@ -245,7 +251,7 @@ async function generatePage( } // Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing) - for (const route of eachRouteInRouteData(pageData)) { + for (const route of eachRouteInRouteData(routeData)) { const integrationRoute = toIntegrationResolvedRoute(route, pipeline.manifest.trailingSlash); const icon = route.type === 'page' || route.type === 'redirect' || route.type === 'fallback' @@ -254,7 +260,7 @@ async function generatePage( logger.info(null, `${icon} ${getPrettyRouteName(route)}`); // Get paths for the route, calling getStaticPaths if needed. - const paths = await getPathsForRoute(route, pageData.component, pageMap, pipeline, builtPaths); + const paths = await getPathsForRoute(route, routeData.component, pageMap, pipeline, builtPaths); // Generate each paths if (config.build.concurrency > 1) { @@ -276,9 +282,9 @@ async function generatePage( } } -function* eachRouteInRouteData(data: PageBuildData) { - yield data.route; - for (const fallbackRoute of data.route.fallbackRoutes) { +function* eachRouteInRouteData(route: RouteData) { + yield route; + for (const fallbackRoute of route.fallbackRoutes) { yield fallbackRoute; } } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 8ce45b327ac7..3eb30a5e76cd 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -87,6 +87,7 @@ export interface BuildInternals { clientInput: Set; manifestFileName?: string; + prerenderEntryFileName?: string; componentMetadata: SSRResult['componentMetadata']; middlewareEntryPoint: URL | undefined; astroActionsEntryPoint: URL | undefined; diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index d663a81686d9..1d74628bd955 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -5,7 +5,7 @@ import type { SSRElement, SSRResult, } from '../../types/public/internal.js'; -import { VIRTUAL_PAGE_MODULE_ID } from '../../vite-plugin-pages/index.js'; +import { VIRTUAL_PAGE_MODULE_ID, VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/index.js'; import { getVirtualModulePageName } from '../../vite-plugin-pages/util.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import type { SSRManifest } from '../app/types.js'; @@ -18,8 +18,7 @@ import { createDefaultRoutes } from '../routing/default.js'; import { findRouteToRewrite } from '../routing/rewrite.js'; import { getOutDirWithinCwd } from './common.js'; import { type BuildInternals, cssOrder, getPageData, mergeInlineCss } from './internal.js'; -import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js'; -import { i18nHasFallback } from './util.js'; +import type { SinglePageBuiltModule, StaticBuildOptions } from './types.js'; /** * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. @@ -141,42 +140,23 @@ export class BuildPipeline extends Pipeline { * It collects the routes to generate during the build. * It returns a map of page information and their relative entry point as a string. */ - retrieveRoutesToGenerate(): Map { - const pages = new Map(); + retrieveRoutesToGenerate(): Map { + const pages = new Map(); - for (const pageData of this.internals.pagesByKeys.values()) { - if (routeIsRedirect(pageData.route)) { - pages.set(pageData, pageData.component); - } else if ( - routeIsFallback(pageData.route) && - (i18nHasFallback(this.config) || - (routeIsFallback(pageData.route) && pageData.route.route === '/')) - ) { - // The original component is transformed during the first build, so we have to retrieve - // the actual `.mjs` that was created. - // During the build, we transform the names of our pages with some weird name, and those weird names become the keys of a map. - // The values of the map are the actual `.mjs` files that are generated during the build + for(const { routeData } of this.manifest.routes) { + // Here, we take the component path and transform it in the virtual module name + const moduleSpecifier = getVirtualModulePageName(VIRTUAL_PAGE_RESOLVED_MODULE_ID, routeData.component); + // We retrieve the original JS module + const filePath = this.internals.entrySpecifierToBundleMap.get(moduleSpecifier); + if (filePath) { + // it exists, added it to pages to render, using the file path that we just retrieved + pages.set(routeData, filePath); - // Here, we take the component path and transform it in the virtual module name - const moduleSpecifier = getVirtualModulePageName(VIRTUAL_PAGE_MODULE_ID, pageData.component); - // We retrieve the original JS module - const filePath = this.internals.entrySpecifierToBundleMap.get(moduleSpecifier); - if (filePath) { - // it exists, added it to pages to render, using the file path that we just retrieved - pages.set(pageData, filePath); - } - } - // Regular page - else { - // TODO: The value doesn't matter anymore. In a future refactor, we can remove it from the Map entirely. - pages.set(pageData, ''); + // Populate the cache + this.#routesByFilePath.set(routeData, filePath); } } - for (const [buildData, filePath] of pages.entries()) { - this.#routesByFilePath.set(buildData.route, filePath); - } - return pages; } diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts index 452539d757db..f9018a21fa34 100644 --- a/packages/astro/src/core/build/plugins/plugin-css.ts +++ b/packages/astro/src/core/build/plugins/plugin-css.ts @@ -45,7 +45,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { name: 'astro:rollup-plugin-build-css', applyToEnvironment(environment) { - return environment.name === 'client' || environment.name === 'ssr'; + return environment.name === 'client' || environment.name === 'ssr' || environment.name === 'prerender'; }, async generateBundle(_outputOptions, bundle) { @@ -105,7 +105,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { name: 'astro:rollup-plugin-single-css', enforce: 'post', applyToEnvironment(environment) { - return environment.name === 'client' || environment.name === 'ssr'; + return environment.name === 'client' || environment.name === 'ssr' || environment.name === 'prerender'; }, configResolved(config) { resolvedConfig = config; @@ -131,7 +131,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { name: 'astro:rollup-plugin-inline-stylesheets', enforce: 'post', applyToEnvironment(environment) { - return environment.name === 'client' || environment.name === 'ssr'; + return environment.name === 'client' || environment.name === 'ssr' || environment.name === 'prerender'; }, configResolved(config) { assetsInlineLimit = config.build.assetsInlineLimit; diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index f52d745a2af9..e28eaf043fab 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -75,7 +75,7 @@ export async function manifestBuildPostHook( }, ) { const manifest = await createManifest(options, internals); - + if(ssrOutputs.length > 0) { let manifestEntryChunk: OutputChunk | undefined; @@ -153,7 +153,8 @@ async function createManifest( const staticFiles = internals.staticFiles; const encodedKey = await encodeKey(await buildOpts.key); - return await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey); + const manifest = await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey); + return manifest; } /** @@ -207,32 +208,10 @@ async function buildManifest( }); } - for (const route of opts.routesList.routes) { - if (!route.prerender) continue; - if (!route.pathname) continue; - - const outFolder = getOutFolder(opts.settings, route.pathname, route); - const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route); - const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); - routes.push({ - file, - links: [], - scripts: [], - styles: [], - routeData: serializeRouteData(route, settings.config.trailingSlash), - }); - staticFiles.push(file); - } - - const needsStaticHeaders = settings.adapter?.adapterFeatures?.experimentalStaticHeaders ?? false; - for (const route of opts.routesList.routes) { const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component)); if (!pageData) continue; - if (route.prerender && route.type !== 'redirect' && !needsStaticHeaders) { - continue; - } const scripts: SerializedRouteInfo['scripts'] = []; if (settings.scripts.some((script) => script.stage === 'page')) { const src = entryModules[PAGE_SCRIPT_ID]; @@ -264,6 +243,14 @@ async function buildManifest( styles, routeData: serializeRouteData(route, settings.config.trailingSlash), }); + + // Add the built .html file as a staticFile + if(route.prerender && route.pathname) { + const outFolder = getOutFolder(opts.settings, route.pathname, route); + const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route); + const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); + staticFiles.push(file); + } } /** diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 3a68c7b0a44e..e0499c13d9e4 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -26,6 +26,8 @@ import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { StaticBuildOptions } from './types.js'; import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js'; +const PRERENDER_ENTRY_FILENAME_PREFIX = 'prerender-entry'; + export async function viteBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -205,11 +207,12 @@ async function buildEnvironments( ...(viteConfig.environments ?? {}), prerender: { build: { + emitAssets: true, outDir: fileURLToPath(new URL('./.prerender/', out)), rollupOptions: { input: 'astro/entrypoints/prerender', output: { - entryFileNames: 'prerender-entry.mjs', + entryFileNames: `${PRERENDER_ENTRY_FILENAME_PREFIX}.[hash].mjs`, format: 'esm', }, }, @@ -218,6 +221,7 @@ async function buildEnvironments( }, client: { build: { + emitAssets: true, target: 'esnext', emptyOutDir: false, outDir: fileURLToPath(getClientOutputDirectory(settings)), @@ -225,6 +229,11 @@ async function buildEnvironments( sourcemap: false, rollupOptions: { preserveEntrySignatures: 'exports-only', + output: { + entryFileNames: `${settings.config.build.assets}/[name].[hash].js`, + chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`, + assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`, + }, } }, }, @@ -252,6 +261,9 @@ async function buildEnvironments( // Build prerender environment for static generation const prerenderOutput = await builder.build(builder.environments.prerender); + // Extract prerender entry filename and store in internals + extractPrerenderEntryFileName(internals, prerenderOutput); + // Build client environment // We must discover client inputs after SSR build because hydration/client-only directives // are only detected during SSR. We mutate the config here since the builder was already created @@ -265,6 +277,42 @@ async function buildEnvironments( type MutateChunk = (chunk: vite.Rollup.OutputChunk, targets: string[], newCode: string) => void; +/** + * Finds and returns the prerender entry filename from the build output. + * Throws an error if no prerender entry file is found. + */ +function getPrerenderEntryFileName( + prerenderOutput: vite.Rollup.RollupOutput | vite.Rollup.RollupOutput[] | vite.Rollup.RollupWatcher, +): string { + const outputs = viteBuildReturnToRollupOutputs(prerenderOutput as any); + + for (const output of outputs) { + for (const chunk of output.output) { + if (chunk.type !== 'asset' && 'fileName' in chunk) { + const fileName = chunk.fileName; + if (fileName.startsWith(PRERENDER_ENTRY_FILENAME_PREFIX)) { + return fileName; + } + } + } + } + + throw new Error( + 'Could not find the prerender entry point in the build output. This is likely a bug in Astro.' + ); +} + +/** + * Extracts the prerender entry filename from the build output + * and stores it in internals for later retrieval in generatePages. + */ +function extractPrerenderEntryFileName( + internals: BuildInternals, + prerenderOutput: vite.Rollup.RollupOutput | vite.Rollup.RollupOutput[] | vite.Rollup.RollupWatcher, +) { + internals.prerenderEntryFileName = getPrerenderEntryFileName(prerenderOutput); +} + async function runManifestInjection( opts: StaticBuildOptions, internals: BuildInternals, diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index b6b313254379..9a0655b65b11 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -31,15 +31,6 @@ export function shouldAppendForwardSlash( } } -export function i18nHasFallback(config: AstroConfig): boolean { - if (config.i18n && config.i18n.fallback) { - // we have some fallback and the control is not none - return Object.keys(config.i18n.fallback).length > 0; - } - - return false; -} - export function encodeName(name: string): string { // Detect if the chunk name has as % sign that is not encoded. // This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391 diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index b39faf30abab..b32b03ad89d2 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -68,10 +68,12 @@ export function serializedManifestPlugin({ middleware: () => import('${MIDDLEWARE_MODULE_ID}'), sessionDriver: () => import('${VIRTUAL_SESSION_DRIVER_ID}'), serverIslandMappings: () => import('${SERVER_ISLAND_MANIFEST}'), - routes, + // _manifest.routes contains enriched route info with scripts and styles, + // while routes only has raw route data. Fallback to routes if _manifest.routes is not available. + routes: _manifest.routes ?? routes, pageMap, - }) - export { manifest } + }); + export { manifest }; `; return { code }; } diff --git a/packages/astro/test/asset-url-base.test.js b/packages/astro/test/asset-url-base.test.js index 18269b6540b1..ab97682f8cfb 100644 --- a/packages/astro/test/asset-url-base.test.js +++ b/packages/astro/test/asset-url-base.test.js @@ -23,6 +23,7 @@ describe('Asset URL resolution in build', () => { const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); const href = $('link[rel=stylesheet]').attr('href'); + assert.ok(href); assert.equal(href.startsWith('/sub/path/'), false); }); });