diff --git a/.changeset/eleven-pens-glow.md b/.changeset/eleven-pens-glow.md new file mode 100644 index 000000000000..d031fba49c70 --- /dev/null +++ b/.changeset/eleven-pens-glow.md @@ -0,0 +1,41 @@ +--- +'astro': minor +--- + +Deprecates the option for route-generating files to export a dynamic value for `prerender`. Only static values are now supported (e.g. `export const prerender = true` or `= false`). This allows for better treeshaking and bundling configuration in the future. + +Adds a new [`"astro:route:setup"` hook](https://docs.astro.build/en/reference/integrations-reference/#astroroutesetup) to the Integrations API to allow you to dynamically set options for a route at build or request time through an integration, such as enabling [on-demand server rendering](https://docs.astro.build/en/guides/server-side-rendering/#opting-in-to-pre-rendering-in-server-mode). + +To migrate from a dynamic export to the new hook, update or remove any dynamic `prerender` exports from individual routing files: + +```diff +// src/pages/blog/[slug].astro +- export const prerender = import.meta.env.PRERENDER +``` + +Instead, create an integration with the `"astro:route:setup"` hook and update the route's `prerender` option: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import { loadEnv } from 'vite'; + +export default defineConfig({ + integrations: [setPrerender()], +}); + +function setPrerender() { + const { PRERENDER } = loadEnv(process.env.NODE_ENV, process.cwd(), ''); + + return { + name: 'set-prerender', + hooks: { + 'astro:route:setup': ({ route }) => { + if (route.component.endsWith('/blog/[slug].astro')) { + route.prerender = PRERENDER; + } + }, + }, + }; +} +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e9c42954a10d..645b07900dd3 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2223,6 +2223,21 @@ export interface ResolvedInjectedRoute extends InjectedRoute { resolvedEntryPoint?: URL; } +export interface RouteOptions { + /** + * The path to this route relative to the project root. The slash is normalized as forward slash + * across all OS. + * @example "src/pages/blog/[...slug].astro" + */ + readonly component: string; + /** + * Whether this route should be prerendered. If the route has an explicit `prerender` export, + * the value will be passed here. Otherwise, it's undefined and will fallback to a prerender + * default depending on the `output` option. + */ + prerender?: boolean; +} + /** * Resolved Astro Config * Config with user settings along with all defaults filled in. @@ -3128,6 +3143,10 @@ declare global { logger: AstroIntegrationLogger; cacheManifest: boolean; }) => void | Promise; + 'astro:route:setup': (options: { + route: RouteOptions; + logger: AstroIntegrationLogger; + }) => void | Promise; } } } diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 0570d9d5d584..23a4068a8bda 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -132,7 +132,7 @@ export async function createVite( // The server plugin is for dev only and having it run during the build causes // the build to run very slow as the filewatcher is triggered often. mode !== 'build' && vitePluginAstroServer({ settings, logger, fs }), - envVitePlugin({ settings }), + envVitePlugin({ settings, logger }), astroEnv({ settings, mode, fs, sync }), markdownVitePlugin({ settings, logger }), htmlVitePlugin(), diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 2dd4ddd3b411..654d1982994c 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -153,7 +153,7 @@ export function isPage(file: URL, settings: AstroSettings): boolean { export function isEndpoint(file: URL, settings: AstroSettings): boolean { if (!isInPagesDir(file, settings.config)) return false; if (!isPublicRoute(file, settings.config)) return false; - return !endsWithPageExt(file, settings); + return !endsWithPageExt(file, settings) && !file.toString().includes('?astro'); } export function isServerLikeOutput(config: AstroConfig) { diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 9b2859e48b3c..e3de85ddd5f6 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -13,6 +13,7 @@ import type { DataEntryType, HookParameters, RouteData, + RouteOptions, } from '../@types/astro.js'; import type { SerializedSSRManifest } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; @@ -558,6 +559,47 @@ export async function runHookBuildDone({ } } +export async function runHookRouteSetup({ + route, + settings, + logger, +}: { + route: RouteOptions; + settings: AstroSettings; + logger: Logger; +}) { + const prerenderChangeLogs: { integrationName: string; value: boolean | undefined }[] = []; + + for (const integration of settings.config.integrations) { + if (integration?.hooks?.['astro:route:setup']) { + const originalRoute = { ...route }; + const integrationLogger = getLogger(integration, logger); + + await withTakingALongTimeMsg({ + name: integration.name, + hookName: 'astro:route:setup', + hookResult: integration.hooks['astro:route:setup']({ + route, + logger: integrationLogger, + }), + logger, + }); + + 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 function isFunctionPerRouteEnabled(adapter: AstroAdapter | undefined): boolean { if (adapter?.adapterFeatures?.functionPerRoute === true) { return true; diff --git a/packages/astro/src/vite-plugin-env/index.ts b/packages/astro/src/vite-plugin-env/index.ts index ea75e752f581..350a29b7ffe1 100644 --- a/packages/astro/src/vite-plugin-env/index.ts +++ b/packages/astro/src/vite-plugin-env/index.ts @@ -1,12 +1,15 @@ import { fileURLToPath } from 'node:url'; import { transform } from 'esbuild'; +import { bold } from 'kleur/colors'; import MagicString from 'magic-string'; import type * as vite from 'vite'; import { loadEnv } from 'vite'; import type { AstroConfig, AstroSettings } from '../@types/astro.js'; +import type { Logger } from '../core/logger/core.js'; interface EnvPluginOptions { settings: AstroSettings; + logger: Logger; } // Match `import.meta.env` directly without trailing property access @@ -116,7 +119,7 @@ async function replaceDefine( }; } -export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plugin { +export default function envVitePlugin({ settings, logger }: EnvPluginOptions): vite.Plugin { let privateEnv: Record; let defaultDefines: Record; let isDev: boolean; @@ -170,13 +173,25 @@ export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plug s.prepend(devImportMetaEnvPrepend); // EDGE CASE: We need to do a static replacement for `export const prerender` for `vite-plugin-scanner` + // TODO: Remove in Astro 5 + let exportConstPrerenderStr: string | undefined; s.replace(exportConstPrerenderRe, (m, key) => { + exportConstPrerenderStr = m; if (privateEnv[key] != null) { return `export const prerender = ${privateEnv[key]}`; } else { return m; } }); + if (exportConstPrerenderStr) { + logger.warn( + 'router', + `Exporting dynamic values from prerender is deprecated. Please use an integration with the "astro:route:setup" hook ` + + `to update the route's \`prerender\` option instead. This allows for better treeshaking and bundling configuration ` + + `in the future. See https://docs.astro.build/en/reference/integrations-reference/#astroroutesetup for a migration example.` + + `\nFound \`${bold(exportConstPrerenderStr)}\` in ${bold(id)}.`, + ); + } return { code: s.toString(), diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts index 05be99a07422..842857777a87 100644 --- a/packages/astro/src/vite-plugin-scanner/index.ts +++ b/packages/astro/src/vite-plugin-scanner/index.ts @@ -2,11 +2,13 @@ import { extname } from 'node:path'; import { bold } from 'kleur/colors'; import type { Plugin as VitePlugin } from 'vite'; import { normalizePath } from 'vite'; -import type { AstroSettings } from '../@types/astro.js'; +import type { AstroSettings, RouteOptions } from '../@types/astro.js'; import { type Logger } from '../core/logger/core.js'; import { isEndpoint, isPage, isServerLikeOutput } from '../core/util.js'; import { rootRelativePath } from '../core/viteUtils.js'; +import { runHookRouteSetup } from '../integrations/hooks.js'; import { getPrerenderDefault } from '../prerender/utils.js'; +import type { PageOptions } from '../vite-plugin-astro/types.js'; import { scan } from './scan.js'; export interface AstroPluginScannerOptions { @@ -39,12 +41,8 @@ export default function astroScannerPlugin({ const fileIsPage = isPage(fileURL, settings); const fileIsEndpoint = isEndpoint(fileURL, settings); if (!(fileIsPage || fileIsEndpoint)) return; - const defaultPrerender = getPrerenderDefault(settings.config); - const pageOptions = await scan(code, id, settings); + const pageOptions = await getPageOptions(code, id, fileURL, settings, logger); - if (typeof pageOptions.prerender === 'undefined') { - pageOptions.prerender = defaultPrerender; - } // `getStaticPaths` warning is just a string check, should be good enough for most cases if ( !pageOptions.prerender && @@ -76,3 +74,29 @@ export default function astroScannerPlugin({ }, }; } + +async function getPageOptions( + code: string, + id: string, + fileURL: URL, + settings: AstroSettings, + logger: Logger, +): Promise { + // Run initial scan + const pageOptions = await scan(code, id, settings); + + // Run integration hooks to alter page options + const route: RouteOptions = { + component: rootRelativePath(settings.config.root, fileURL, false), + prerender: pageOptions.prerender, + }; + await runHookRouteSetup({ route, settings, logger }); + pageOptions.prerender = route.prerender; + + // Fallback if unset + if (typeof pageOptions.prerender === 'undefined') { + pageOptions.prerender = getPrerenderDefault(settings.config); + } + + return pageOptions; +} diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js index d856d9d3e752..e699a1b3c01b 100644 --- a/packages/integrations/node/test/prerender.test.js +++ b/packages/integrations/node/test/prerender.test.js @@ -57,6 +57,7 @@ describe('Prerendering', () => { assert.equal(res.status, 200); assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); }); it('Can render prerendered route with redirect and query params', async () => { @@ -131,6 +132,7 @@ describe('Prerendering', () => { assert.equal(res.status, 200); assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); }); it('Can render prerendered route with redirect and query params', async () => { @@ -152,6 +154,64 @@ describe('Prerendering', () => { }); }); + describe('Via integration', () => { + before(async () => { + process.env.PRERENDER = false; + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'server', + outDir: './dist/via-integration', + build: { + client: './dist/via-integration/client', + server: './dist/via-integration/server', + }, + adapter: nodejs({ mode: 'standalone' }), + integrations: [ + { + name: 'test', + hooks: { + 'astro:route:setup': ({ route }) => { + if (route.component.endsWith('two.astro')) { + route.prerender = true; + } + }, + }, + }, + ], + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + let res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + delete process.env.PRERENDER; + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); + }); + }); + describe('Dev', () => { let devServer; @@ -243,6 +303,7 @@ describe('Hybrid rendering', () => { assert.equal(res.status, 200); assert.equal($('h1').text(), 'One'); + assert.ok(fixture.pathExists('/client/one/index.html')); }); it('Can render prerendered route with redirect and query params', async () => { @@ -316,6 +377,7 @@ describe('Hybrid rendering', () => { assert.equal(res.status, 200); assert.equal($('h1').text(), 'One'); + assert.ok(fixture.pathExists('/client/one/index.html')); }); it('Can render prerendered route with redirect and query params', async () => {