summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Nate Moore <natemoo-re@users.noreply.github.com> 2022-12-16 11:38:37 -0500
committerGravatar GitHub <noreply@github.com> 2022-12-16 10:38:37 -0600
commitd2960984c59af7b60a3ea472c6c58fb00534a8e6 (patch)
tree4247b41b81014b21f521f75735fe099218f64a7e
parent7cbe7f5623ef65739bd596267237cd03634668d0 (diff)
downloadastro-d2960984c59af7b60a3ea472c6c58fb00534a8e6.tar.gz
astro-d2960984c59af7b60a3ea472c6c58fb00534a8e6.tar.zst
astro-d2960984c59af7b60a3ea472c6c58fb00534a8e6.zip
Experimental Prerender API (#5297)
* wip: hybrid output * wip: hybrid output mvp * refactor: move hybrid => server * wip: hybrid support for `output: 'server'` * feat(hybrid): overwrite static files * fix: update static build * feat(hybrid): skip page generation if no static entrypoints * feat: migrate from hybrid output => prerender flag * fix: appease typescript * fix: appease typescript * fix: appease typescript * fix: appease typescript * fix: improve static cleanup * attempt: avoid preprocess scanning * hack: force generated .js files to be treated as ESM * better handling for astro metadata * fix: update scanner plugin * fix: page name bug * fix: keep ssr false when generating pages * fix: force output to be treated as ESM * fix: client output should respect buildConfig * fix: ensure outDir is always created * fix: do not replace files with noop * fix(netlify): add support for `experimental_prerender` pages * feat: switch to `experimental_prerender` * chore: update es-module-lexer code in test * feat: improved code-splitting, cleanup * feat: move prerender behind flag * test: prerender * test: update prerender test * chore: update lockfile * fix: only match `.html` files when resolving assets * chore: update changeset * chore: remove ESM hack * chore: allow `--experimental-prerender` flag, move `--experimental-error-overlay` into subobject * chore: update changeset * test(vite-plugin-scanner): add proper unit tests for vite-plugin-scanner * chore: remove leftover code * chore: add comment on cleanup task * refactor: move manual chunks logic to vite-plugin-prerender * fix: do not support let declarations * test: add var test * refactor: prefer existing util * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank <swithinbank@gmail.com> * Update packages/astro/src/core/errors/errors-data.ts Co-authored-by: Chris Swithinbank <swithinbank@gmail.com> * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank <swithinbank@gmail.com> Co-authored-by: Nate Moore <nate@astro.build> Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
-rw-r--r--.changeset/funny-waves-worry.md17
-rw-r--r--packages/astro/e2e/error-cyclic.test.js2
-rw-r--r--packages/astro/e2e/error-react-spectrum.test.js2
-rw-r--r--packages/astro/e2e/error-sass.test.js2
-rw-r--r--packages/astro/e2e/errors.test.js2
-rw-r--r--packages/astro/package.json2
-rw-r--r--packages/astro/src/@types/astro.ts39
-rw-r--r--packages/astro/src/core/app/index.ts4
-rw-r--r--packages/astro/src/core/build/common.ts9
-rw-r--r--packages/astro/src/core/build/generate.ts31
-rw-r--r--packages/astro/src/core/build/internal.ts40
-rw-r--r--packages/astro/src/core/build/static-build.ts154
-rw-r--r--packages/astro/src/core/build/types.ts2
-rw-r--r--packages/astro/src/core/build/vite-plugin-analyzer.ts12
-rw-r--r--packages/astro/src/core/build/vite-plugin-internals.ts7
-rw-r--r--packages/astro/src/core/build/vite-plugin-pages.ts4
-rw-r--r--packages/astro/src/core/build/vite-plugin-prerender.ts43
-rw-r--r--packages/astro/src/core/build/vite-plugin-ssr.ts20
-rw-r--r--packages/astro/src/core/config/config.ts8
-rw-r--r--packages/astro/src/core/config/schema.ts13
-rw-r--r--packages/astro/src/core/create-vite.ts2
-rw-r--r--packages/astro/src/core/errors/errors-data.ts16
-rw-r--r--packages/astro/src/core/errors/overlay.ts2
-rw-r--r--packages/astro/src/core/routing/match.ts13
-rw-r--r--packages/astro/src/core/util.ts6
-rw-r--r--packages/astro/src/integrations/index.ts2
-rw-r--r--packages/astro/src/jsx/babel.ts1
-rw-r--r--packages/astro/src/vite-plugin-astro/index.ts1
-rw-r--r--packages/astro/src/vite-plugin-astro/types.ts5
-rw-r--r--packages/astro/src/vite-plugin-markdown-legacy/index.ts1
-rw-r--r--packages/astro/src/vite-plugin-markdown/index.ts1
-rw-r--r--packages/astro/src/vite-plugin-scanner/index.ts44
-rw-r--r--packages/astro/src/vite-plugin-scanner/scan.ts48
-rw-r--r--packages/astro/test/fixtures/ssr-prerender/package.json8
-rw-r--r--packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro18
-rw-r--r--packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro12
-rw-r--r--packages/astro/test/ssr-prerender.test.js52
-rw-r--r--packages/astro/test/units/vite-plugin-astro/compile.test.js7
-rw-r--r--packages/astro/test/units/vite-plugin-scanner/scan.test.js79
-rw-r--r--packages/integrations/netlify/src/shared.ts18
-rw-r--r--pnpm-lock.yaml20
41 files changed, 701 insertions, 68 deletions
diff --git a/.changeset/funny-waves-worry.md b/.changeset/funny-waves-worry.md
new file mode 100644
index 000000000..361fac880
--- /dev/null
+++ b/.changeset/funny-waves-worry.md
@@ -0,0 +1,17 @@
+---
+'astro': minor
+'@astrojs/netlify': minor
+---
+
+Introduces the **experimental** Prerender API.
+
+> **Note**
+> This API is not yet stable and is subject to possible breaking changes!
+
+- Deploy an Astro server without sacrificing the speed or cacheability of static HTML.
+- The Prerender API allows you to statically prerender specific `pages/` at build time.
+
+**Usage**
+
+- First, run `astro build --experimental-prerender` or enable `experimental: { prerender: true }` in your `astro.config.mjs` file.
+- Then, include `export const prerender = true` in any file in the `pages/` directory that you wish to prerender.
diff --git a/packages/astro/e2e/error-cyclic.test.js b/packages/astro/e2e/error-cyclic.test.js
index 98c3f19d5..5bdef236e 100644
--- a/packages/astro/e2e/error-cyclic.test.js
+++ b/packages/astro/e2e/error-cyclic.test.js
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayContent } from './test-utils.js';
const test = testFactory({
- experimentalErrorOverlay: true,
+ experimental: { errorOverlay: true },
root: './fixtures/error-cyclic/',
});
diff --git a/packages/astro/e2e/error-react-spectrum.test.js b/packages/astro/e2e/error-react-spectrum.test.js
index eacaaadc2..05b1cf2a2 100644
--- a/packages/astro/e2e/error-react-spectrum.test.js
+++ b/packages/astro/e2e/error-react-spectrum.test.js
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayContent } from './test-utils.js';
const test = testFactory({
- experimentalErrorOverlay: true,
+ experimental: { errorOverlay: true },
root: './fixtures/error-react-spectrum/',
});
diff --git a/packages/astro/e2e/error-sass.test.js b/packages/astro/e2e/error-sass.test.js
index 5fbd10976..ec8ab89f2 100644
--- a/packages/astro/e2e/error-sass.test.js
+++ b/packages/astro/e2e/error-sass.test.js
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test';
import { testFactory, getErrorOverlayContent } from './test-utils.js';
const test = testFactory({
- experimentalErrorOverlay: true,
+ experimental: { errorOverlay: true },
root: './fixtures/error-sass/',
});
diff --git a/packages/astro/e2e/errors.test.js b/packages/astro/e2e/errors.test.js
index ba43851b0..0de23f7c1 100644
--- a/packages/astro/e2e/errors.test.js
+++ b/packages/astro/e2e/errors.test.js
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test';
import { getErrorOverlayContent, testFactory } from './test-utils.js';
const test = testFactory({
- experimentalErrorOverlay: true,
+ experimental: { errorOverlay: true },
root: './fixtures/errors/',
});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 7b9d13a6d..4e9e9dc91 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -123,7 +123,7 @@
"debug": "^4.3.4",
"deepmerge-ts": "^4.2.2",
"diff": "^5.1.0",
- "es-module-lexer": "^0.10.5",
+ "es-module-lexer": "^1.1.0",
"execa": "^6.1.0",
"fast-glob": "^3.2.11",
"github-slugger": "^1.4.0",
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 55620c745..d5ff981d7 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -83,6 +83,7 @@ export interface CLIFlags {
config?: string;
drafts?: boolean;
experimentalErrorOverlay?: boolean;
+ experimentalPrerender?: boolean;
}
export interface BuildConfig {
@@ -895,11 +896,41 @@ export interface AstroUserConfig {
astroFlavoredMarkdown?: boolean;
};
- /**
- * @hidden
- * Turn on experimental support for the new error overlay component.
+ /**
+ * @docs
+ * @kind heading
+ * @name Experimental Flags
+ * @description
+ * Astro offers experimental flags to give users early access to new features.
+ * These flags are not guaranteed to be stable.
*/
- experimentalErrorOverlay?: boolean;
+ experimental?: {
+ /**
+ * @hidden
+ * Turn on experimental support for the new error overlay component.
+ */
+ errorOverlay?: boolean;
+ /**
+ * @docs
+ * @name experimental.prerender
+ * @type {boolean}
+ * @default `false`
+ * @version 1.7.0
+ * @description
+ * Enable experimental support for prerendered pages when generating a server.
+ *
+ * To enable this feature, set `experimental.prerender` to `true` in your Astro config:
+ *
+ * ```js
+ * {
+ * experimental: {
+ * prerender: true,
+ * },
+ * }
+ * ```
+ */
+ prerender?: boolean;
+ };
// Legacy options to be removed
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index c7b8616ef..529a450f9 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -25,7 +25,7 @@ import {
createLinkStylesheetElementSet,
createModuleScriptElement,
} from '../render/ssr-element.js';
-import { matchRoute } from '../routing/match.js';
+import { matchAssets, matchRoute } from '../routing/match.js';
export { deserializeManifest } from './common.js';
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
@@ -100,6 +100,8 @@ export class App {
let routeData = matchRoute(pathname, this.#manifestData);
if (routeData) {
+ const asset = matchAssets(routeData, this.#manifest.assets);
+ if (asset) return undefined;
return routeData;
} else if (matchNotFound) {
return matchRoute('/404', this.#manifestData);
diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts
index e05ce6d9e..642db2fe5 100644
--- a/packages/astro/src/core/build/common.ts
+++ b/packages/astro/src/core/build/common.ts
@@ -1,4 +1,5 @@
import npath from 'path';
+import { createHash } from 'crypto'
import { fileURLToPath, pathToFileURL } from 'url';
import type { AstroConfig, RouteType } from '../../@types/astro';
import { appendForwardSlash } from '../../core/path.js';
@@ -7,7 +8,11 @@ const STATUS_CODE_PAGES = new Set(['/404', '/500']);
const FALLBACK_OUT_DIR_NAME = './.astro/';
function getOutRoot(astroConfig: AstroConfig): URL {
- return new URL('./', astroConfig.outDir);
+ if (astroConfig.output === 'static') {
+ return new URL('./', astroConfig.outDir);
+ } else {
+ return new URL('./', astroConfig.build.client);
+ }
}
export function getOutFolder(
@@ -41,7 +46,7 @@ export function getOutFile(
astroConfig: AstroConfig,
outFolder: URL,
pathname: string,
- routeType: RouteType
+ routeType: RouteType,
): URL {
switch (routeType) {
case 'endpoint':
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index a92006fe1..ba95faadc 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -12,7 +12,7 @@ import type {
RouteType,
SSRLoadedRenderer,
} from '../../@types/astro';
-import type { BuildInternals } from '../../core/build/internal.js';
+import { BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js';
import {
prependForwardSlash,
removeLeadingForwardSlash,
@@ -29,7 +29,12 @@ import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
-import { eachPageData, getPageDataByComponent, sortedCSS } from './internal.js';
+import {
+ eachPrerenderedPageData,
+ eachPageData,
+ getPageDataByComponent,
+ sortedCSS,
+} from './internal.js';
import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js';
@@ -70,17 +75,27 @@ export function chunkIsPage(
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
const timer = performance.now();
- info(opts.logging, null, `\n${bgGreen(black(' generating static routes '))}`);
-
const ssr = opts.settings.config.output === 'server';
const serverEntry = opts.buildConfig.serverEntry;
const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
+
+ if (opts.settings.config.experimental.prerender && opts.settings.config.output === 'server' && !hasPrerenderedPages(internals)) return;
+
+ const verb = ssr ? 'prerendering' : 'generating';
+ info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`);
+
const ssrEntryURL = new URL('./' + serverEntry + `?time=${Date.now()}`, outFolder);
const ssrEntry = await import(ssrEntryURL.toString());
const builtPaths = new Set<string>();
- for (const pageData of eachPageData(internals)) {
- await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
+ if (opts.settings.config.experimental.prerender && opts.settings.config.output === 'server') {
+ for (const pageData of eachPrerenderedPageData(internals)) {
+ await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
+ }
+ } else {
+ for (const pageData of eachPageData(internals)) {
+ await generatePage(opts, internals, pageData, ssrEntry, builtPaths);
+ }
}
await runHookBuildGenerated({
@@ -106,7 +121,7 @@ async function generatePage(
const linkIds: string[] = sortedCSS(pageData);
const scripts = pageInfo?.hoistedScript ?? null;
- const pageModule = ssrEntry.pageMap.get(pageData.component);
+ const pageModule = ssrEntry.pageMap?.get(pageData.component);
if (!pageModule) {
throw new Error(
@@ -163,7 +178,7 @@ async function getPathsForRoute(
route: pageData.route,
isValidate: false,
logging: opts.logging,
- ssr: opts.settings.config.output === 'server',
+ ssr: false,
})
.then((_result) => {
const label = _result.staticPaths.length === 1 ? 'page' : 'pages';
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
index ffb7fb7b9..9a7f97326 100644
--- a/packages/astro/src/core/build/internal.ts
+++ b/packages/astro/src/core/build/internal.ts
@@ -1,8 +1,9 @@
import type { OutputChunk, RenderedChunk } from 'rollup';
-import type { PageBuildData, ViteID } from './types';
+import type { PageBuildData, PageOutput, ViteID } from './types';
import { prependForwardSlash, removeFileExtension } from '../path.js';
import { viteID } from '../util.js';
+import { PageOptions } from '../../vite-plugin-astro/types';
export interface BuildInternals {
/**
@@ -21,11 +22,21 @@ export interface BuildInternals {
entrySpecifierToBundleMap: Map<string, string>;
/**
+ * A map to get a specific page's bundled output file.
+ */
+ pageToBundleMap: Map<string, string>;
+
+ /**
* A map for page-specific information.
*/
pagesByComponent: Map<string, PageBuildData>;
/**
+ * A map for page-specific output.
+ */
+ pageOptionsByPage: Map<string, PageOptions>;
+
+ /**
* A map for page-specific information by Vite ID (a path-like string)
*/
pagesByViteID: Map<ViteID, PageBuildData>;
@@ -73,8 +84,10 @@ export function createBuildInternals(): BuildInternals {
hoistedScriptIdToHoistedMap,
hoistedScriptIdToPagesMap,
entrySpecifierToBundleMap: new Map<string, string>(),
+ pageToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(),
+ pageOptionsByPage: new Map(),
pagesByViteID: new Map(),
pagesByClientOnly: new Map(),
@@ -189,6 +202,31 @@ export function* eachPageData(internals: BuildInternals) {
yield* internals.pagesByComponent.values();
}
+export function hasPrerenderedPages(internals: BuildInternals) {
+ for (const id of internals.pagesByViteID.keys()) {
+ if (internals.pageOptionsByPage.get(id)?.prerender) {
+ return true
+ }
+ }
+ return false
+}
+
+export function* eachPrerenderedPageData(internals: BuildInternals) {
+ for (const [id, pageData] of internals.pagesByViteID.entries()) {
+ if (internals.pageOptionsByPage.get(id)?.prerender) {
+ yield pageData;
+ }
+ }
+}
+
+export function* eachServerPageData(internals: BuildInternals) {
+ for (const [id, pageData] of internals.pagesByViteID.entries()) {
+ if (!internals.pageOptionsByPage.get(id)?.prerender) {
+ yield pageData;
+ }
+ }
+}
+
/**
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
* A lower depth means it comes directly from the top-level page.
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 78c14973f..59941f089 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -4,7 +4,11 @@ import { bgGreen, bgMagenta, black, dim } from 'kleur/colors';
import path from 'path';
import { fileURLToPath } from 'url';
import * as vite from 'vite';
-import { BuildInternals, createBuildInternals } from '../../core/build/internal.js';
+import {
+ BuildInternals,
+ createBuildInternals,
+ eachPrerenderedPageData,
+} from '../../core/build/internal.js';
import { emptyDir, removeDir } from '../../core/fs/index.js';
import { prependForwardSlash } from '../../core/path.js';
import { isModeServerWithNoAdapter } from '../../core/util.js';
@@ -18,11 +22,13 @@ import { trackPageData } from './internal.js';
import type { PageBuildData, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js';
import { vitePluginAnalyzer } from './vite-plugin-analyzer.js';
+import { vitePluginPrerender } from './vite-plugin-prerender.js';
import { rollupPluginAstroBuildCSS } from './vite-plugin-css.js';
import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js';
import { vitePluginInternals } from './vite-plugin-internals.js';
import { vitePluginPages } from './vite-plugin-pages.js';
import { injectManifest, vitePluginSSR } from './vite-plugin-ssr.js';
+import * as eslexer from 'es-module-lexer';
export async function staticBuild(opts: StaticBuildOptions) {
const { allPages, settings } = opts;
@@ -88,15 +94,33 @@ export async function staticBuild(opts: StaticBuildOptions) {
await clientBuild(opts, internals, clientInput);
timer.generate = performance.now();
- if (settings.config.output === 'static') {
- await generatePages(opts, internals);
- await cleanSsrOutput(opts);
+ if (!settings.config.experimental.prerender) {
+ if (settings.config.output === 'static') {
+ await generatePages(opts, internals);
+ await cleanServerOutput(opts);
+ } else {
+ // Inject the manifest
+ await injectManifest(opts, internals);
+
+ info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
+ await ssrMoveAssets(opts);
+ }
} else {
- // Inject the manifest
- await injectManifest(opts, internals);
-
- info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
- await ssrMoveAssets(opts);
+ switch (settings.config.output) {
+ case 'static': {
+ await generatePages(opts, internals);
+ await cleanServerOutput(opts);
+ return;
+ }
+ case 'server': {
+ await injectManifest(opts, internals);
+ await generatePages(opts, internals);
+ await cleanStaticOutput(opts, internals);
+ info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
+ await ssrMoveAssets(opts);
+ return;
+ }
+ }
}
}
@@ -134,6 +158,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
reportCompressedSize: false,
},
plugins: [
+ vitePluginAnalyzer(internals),
vitePluginInternals(input, internals),
vitePluginPages(opts, internals),
rollupPluginAstroBuildCSS({
@@ -141,10 +166,10 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
internals,
target: 'server',
}),
+ vitePluginPrerender(opts, internals),
...(viteConfig.plugins || []),
// SSR needs to be last
- settings.config.output === 'server' && vitePluginSSR(internals, settings.adapter!),
- vitePluginAnalyzer(internals),
+ ssr && vitePluginSSR(internals, settings.adapter!),
],
envPrefix: 'PUBLIC_',
base: settings.config.base,
@@ -169,7 +194,12 @@ async function clientBuild(
const { settings, viteConfig } = opts;
const timer = performance.now();
const ssr = settings.config.output === 'server';
- const out = ssr ? opts.buildConfig.client : settings.config.outDir;
+ let out;
+ if (!opts.settings.config.experimental.prerender) {
+ out = ssr ? opts.buildConfig.client : settings.config.outDir;
+ } else {
+ out = ssr ? opts.buildConfig.client : getOutDirWithinCwd(settings.config.outDir);
+ }
// Nothing to do if there is no client-side JS.
if (!input.size) {
@@ -232,7 +262,77 @@ async function clientBuild(
return buildResult;
}
-async function cleanSsrOutput(opts: StaticBuildOptions) {
+/**
+ * For each statically prerendered page, replace their SSR file with a noop.
+ * This allows us to run the SSR build only once, but still remove dependencies for statically rendered routes.
+ */
+async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) {
+ const allStaticFiles = new Set();
+ for (const pageData of eachPrerenderedPageData(internals)) {
+ allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier));
+ }
+ const ssr = opts.settings.config.output === 'server';
+ const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
+ // The SSR output is all .mjs files, the client output is not.
+ const files = await glob('**/*.mjs', {
+ cwd: fileURLToPath(out),
+ });
+
+ if (files.length) {
+ await eslexer.init;
+
+ // Cleanup prerendered chunks.
+ // This has to happen AFTER the SSR build runs as a final step, because we need the code in order to generate the pages.
+ // These chunks should only contain prerendering logic, so they are safe to modify.
+ await Promise.all(
+ files.map(async (filename) => {
+ if (!allStaticFiles.has(filename)) {
+ return;
+ }
+ const url = new URL(filename, out);
+ const text = await fs.promises.readFile(url, { encoding: 'utf8' });
+ const [, exports] = eslexer.parse(text);
+ // Replace exports (only prerendered pages) with a noop
+ let value = 'const noop = () => {};';
+ for (const e of exports) {
+ value += `\nexport const ${e.n} = noop;`;
+ }
+ await fs.promises.writeFile(url, value, { encoding: 'utf8' });
+ })
+ );
+ // Map directories heads from the .mjs files
+ const directories: Set<string> = new Set();
+ files.forEach((i) => {
+ const splitFilePath = i.split(path.sep);
+ // If the path is more than just a .mjs filename itself
+ if (splitFilePath.length > 1) {
+ directories.add(splitFilePath[0]);
+ }
+ });
+ // Attempt to remove only those folders which are empty
+ await Promise.all(
+ Array.from(directories).map(async (filename) => {
+ const url = new URL(filename, out);
+ const folder = await fs.promises.readdir(url);
+ if (!folder.length) {
+ await fs.promises.rm(url, { recursive: true, force: true });
+ }
+ })
+ );
+ }
+
+ if (!opts.settings.config.experimental.prerender) {
+ // Clean out directly if the outDir is outside of root
+ if (out.toString() !== opts.settings.config.outDir.toString()) {
+ // Copy assets before cleaning directory if outside root
+ copyFiles(out, opts.settings.config.outDir);
+ await fs.promises.rm(out, { recursive: true });
+ return;
+ }
+ }
+}
+
+async function cleanServerOutput(opts: StaticBuildOptions) {
const out = getOutDirWithinCwd(opts.settings.config.outDir);
// The SSR output is all .mjs files, the client output is not.
const files = await glob('**/*.mjs', {
@@ -259,8 +359,8 @@ async function cleanSsrOutput(opts: StaticBuildOptions) {
await Promise.all(
Array.from(directories).map(async (filename) => {
const url = new URL(filename, out);
- const folder = await fs.promises.readdir(url);
- if (!folder.length) {
+ const dir = await glob(fileURLToPath(url), { absolute: true });
+ if (!dir.length) {
await fs.promises.rm(url, { recursive: true, force: true });
}
})
@@ -303,16 +403,16 @@ async function ssrMoveAssets(opts: StaticBuildOptions) {
cwd: fileURLToPath(serverRoot),
});
- // Make the directory
- await fs.promises.mkdir(clientAssets, { recursive: true });
-
- await Promise.all(
- files.map(async (filename) => {
- const currentUrl = new URL(filename, serverRoot);
- const clientUrl = new URL(filename, clientRoot);
- return fs.promises.rename(currentUrl, clientUrl);
- })
- );
-
- removeDir(serverAssets);
+ if (files.length > 0) {
+ // Make the directory
+ await fs.promises.mkdir(clientAssets, { recursive: true });
+ await Promise.all(
+ files.map(async (filename) => {
+ const currentUrl = new URL(filename, serverRoot);
+ const clientUrl = new URL(filename, clientRoot);
+ return fs.promises.rename(currentUrl, clientUrl);
+ })
+ );
+ removeDir(serverAssets);
+ }
}
diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts
index 24b2b8f7b..fb5860015 100644
--- a/packages/astro/src/core/build/types.ts
+++ b/packages/astro/src/core/build/types.ts
@@ -1,5 +1,6 @@
import type { InlineConfig } from 'vite';
import type {
+ AstroConfig,
AstroSettings,
BuildConfig,
ComponentInstance,
@@ -13,6 +14,7 @@ import type { RouteCache } from '../render/route-cache';
export type ComponentPath = string;
export type ViteID = string;
+export type PageOutput = AstroConfig['output']
export interface PageBuildData {
component: ComponentPath;
diff --git a/packages/astro/src/core/build/vite-plugin-analyzer.ts b/packages/astro/src/core/build/vite-plugin-analyzer.ts
index 21aa1c2be..0e6a991bd 100644
--- a/packages/astro/src/core/build/vite-plugin-analyzer.ts
+++ b/packages/astro/src/core/build/vite-plugin-analyzer.ts
@@ -74,12 +74,18 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
const hoistScanner = hoistedScriptScanner();
const ids = this.getModuleIds();
+
for (const id of ids) {
const info = this.getModuleInfo(id);
if (!info || !info.meta?.astro) continue;
const astro = info.meta.astro as AstroPluginMetadata['astro'];
+ const pageData = getPageDataByViteID(internals, id);
+ if (pageData) {
+ internals.pageOptionsByPage.set(id, astro.pageOptions);
+ }
+
for (const c of astro.hydratedComponents) {
const rid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier;
internals.discoveredHydratedComponents.add(rid);
@@ -103,10 +109,10 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
}
for (const [pageInfo] of getTopLevelPages(id, this)) {
- const pageData = getPageDataByViteID(internals, pageInfo.id);
- if (!pageData) continue;
+ const newPageData = getPageDataByViteID(internals, pageInfo.id);
+ if (!newPageData) continue;
- trackClientOnlyPageDatas(internals, pageData, clientOnlys);
+ trackClientOnlyPageDatas(internals, newPageData, clientOnlys);
}
}
}
diff --git a/packages/astro/src/core/build/vite-plugin-internals.ts b/packages/astro/src/core/build/vite-plugin-internals.ts
index b0f10f0fd..6e6b1e90c 100644
--- a/packages/astro/src/core/build/vite-plugin-internals.ts
+++ b/packages/astro/src/core/build/vite-plugin-internals.ts
@@ -48,6 +48,13 @@ export function vitePluginInternals(input: Set<string>, internals: BuildInternal
if (chunk.type === 'chunk' && chunk.facadeModuleId) {
const specifier = mapping.get(chunk.facadeModuleId) || chunk.facadeModuleId;
internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName);
+ } else if (chunk.type === 'chunk') {
+ for (const id of Object.keys(chunk.modules)) {
+ const pageData = internals.pagesByViteID.get(id);
+ if (pageData) {
+ internals.pageToBundleMap.set(pageData.moduleSpecifier, chunk.fileName)
+ }
+ }
}
}
},
diff --git a/packages/astro/src/core/build/vite-plugin-pages.ts b/packages/astro/src/core/build/vite-plugin-pages.ts
index 7b81ba398..83e9f4435 100644
--- a/packages/astro/src/core/build/vite-plugin-pages.ts
+++ b/packages/astro/src/core/build/vite-plugin-pages.ts
@@ -1,7 +1,7 @@
import type { Plugin as VitePlugin } from 'vite';
import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../app/index.js';
import { addRollupInput } from './add-rollup-input.js';
-import type { BuildInternals } from './internal.js';
+import { BuildInternals, hasPrerenderedPages } from './internal.js';
import { eachPageData } from './internal.js';
import type { StaticBuildOptions } from './types';
@@ -10,7 +10,7 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern
name: '@astro/plugin-build-pages',
options(options) {
- if (opts.settings.config.output === 'static') {
+ if (opts.settings.config.output === 'static' || hasPrerenderedPages(internals)) {
return addRollupInput(options, [pagesVirtualModuleId]);
}
},
diff --git a/packages/astro/src/core/build/vite-plugin-prerender.ts b/packages/astro/src/core/build/vite-plugin-prerender.ts
new file mode 100644
index 000000000..974bdf41f
--- /dev/null
+++ b/packages/astro/src/core/build/vite-plugin-prerender.ts
@@ -0,0 +1,43 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { BuildInternals } from './internal.js';
+import type { StaticBuildOptions } from './types';
+
+export function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
+ return {
+ name: 'astro:rollup-plugin-prerender',
+
+ outputOptions(outputOptions) {
+ // No-op if `prerender` is not enabled
+ if (!opts.settings.config.experimental.prerender) return;
+
+ const manualChunks = outputOptions.manualChunks || Function.prototype;
+ outputOptions.manualChunks = function (id, api, ...args) {
+ // Defer to user-provided `manualChunks`, if it was provided.
+ if (typeof manualChunks == 'object') {
+ if (id in manualChunks) {
+ return manualChunks[id];
+ }
+ } else if (typeof manualChunks === 'function') {
+ const outid = manualChunks.call(this, id, api, ...args);
+ if (outid) {
+ return outid;
+ }
+ }
+ // Split the Astro runtime into a separate chunk for readability
+ if (id.includes('astro/dist')) {
+ return 'astro';
+ }
+ const pageInfo = internals.pagesByViteID.get(id);
+ if (pageInfo) {
+ // prerendered pages should be split into their own chunk
+ // Important: this can't be in the `pages/` directory!
+ if (api.getModuleInfo(id)?.meta.astro?.pageOptions?.prerender) {
+ return `prerender`;
+ }
+ // pages should go in their own chunks/pages/* directory
+ return `pages${pageInfo.route.route.replace(/\/$/, '/index')}`;
+ }
+ };
+ },
+ }
+}
diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts
index e0d6ca112..03a66e126 100644
--- a/packages/astro/src/core/build/vite-plugin-ssr.ts
+++ b/packages/astro/src/core/build/vite-plugin-ssr.ts
@@ -13,7 +13,8 @@ import { pagesVirtualModuleId } from '../app/index.js';
import { removeLeadingForwardSlash, removeTrailingForwardSlash } from '../path.js';
import { serializeRouteData } from '../routing/index.js';
import { addRollupInput } from './add-rollup-input.js';
-import { eachPageData, sortedCSS } from './internal.js';
+import { eachServerPageData, eachPrerenderedPageData, sortedCSS } from './internal.js';
+import { getOutFile, getOutFolder } from './common.js';
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
@@ -43,6 +44,8 @@ const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), {
});
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
+export * from '${pagesVirtualModuleId}';
+
${
adapter.exports
? `const _exports = adapter.createExports(_manifest, _args);
@@ -136,7 +139,20 @@ function buildManifest(
const bareBase = removeTrailingForwardSlash(removeLeadingForwardSlash(settings.config.base));
const joinBase = (pth: string) => (bareBase ? bareBase + '/' + pth : pth);
- for (const pageData of eachPageData(internals)) {
+ for (const pageData of eachPrerenderedPageData(internals)) {
+ const outFolder = getOutFolder(opts.settings.config, pageData.route.pathname!, pageData.route.type);
+ const outFile = getOutFile(opts.settings.config, outFolder, pageData.route.pathname!, pageData.route.type);
+ const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
+ routes.push({
+ file,
+ links: [],
+ scripts: [],
+ routeData: serializeRouteData(pageData.route, settings.config.trailingSlash),
+ });
+ staticFiles.push(file);
+ }
+
+ for (const pageData of eachServerPageData(internals)) {
const scripts: SerializedRouteInfo['scripts'] = [];
if (pageData.hoistedScript) {
scripts.unshift(
diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts
index 797b3f1ff..3524455f6 100644
--- a/packages/astro/src/core/config/config.ts
+++ b/packages/astro/src/core/config/config.ts
@@ -104,6 +104,10 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
typeof flags.experimentalErrorOverlay === 'boolean'
? flags.experimentalErrorOverlay
: undefined,
+ experimentalPrerender:
+ typeof flags.experimentalPrerender === 'boolean'
+ ? flags.experimentalPrerender
+ : undefined,
};
}
@@ -118,6 +122,7 @@ export function resolveRoot(cwd?: string | URL): string {
function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: string) {
astroConfig.server = astroConfig.server || {};
astroConfig.markdown = astroConfig.markdown || {};
+ astroConfig.experimental = astroConfig.experimental || {};
if (typeof flags.site === 'string') astroConfig.site = flags.site;
if (typeof flags.base === 'string') astroConfig.base = flags.base;
if (typeof flags.drafts === 'boolean') astroConfig.markdown.drafts = flags.drafts;
@@ -131,7 +136,8 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: strin
// TODO: Come back here and refactor to remove this expected error.
astroConfig.server.host = flags.host;
}
- astroConfig.experimentalErrorOverlay = flags.experimentalErrorOverlay ?? false;
+ if (flags.experimentalErrorOverlay) astroConfig.experimental.errorOverlay = true;
+ if (flags.experimentalPrerender) astroConfig.experimental.prerender = true;
return astroConfig;
}
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 33ef28aa4..4c119a859 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -47,7 +47,10 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
legacy: {
astroFlavoredMarkdown: false,
},
- experimentalErrorOverlay: false,
+ experimental: {
+ errorOverlay: false,
+ prerender: false,
+ },
};
export const AstroConfigSchema = z.object({
@@ -188,6 +191,13 @@ export const AstroConfigSchema = z.object({
vite: z
.custom<ViteUserConfig>((data) => data instanceof Object && !Array.isArray(data))
.default(ASTRO_CONFIG_DEFAULTS.vite),
+ experimental: z
+ .object({
+ errorOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.errorOverlay),
+ prerender: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.prerender),
+ })
+ .optional()
+ .default({}),
legacy: z
.object({
astroFlavoredMarkdown: z
@@ -197,7 +207,6 @@ export const AstroConfigSchema = z.object({
})
.optional()
.default({}),
- experimentalErrorOverlay: z.boolean().optional().default(false),
});
interface PostCSSConfigResult {
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index 5b8991f02..9b6b9b3ab 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -20,6 +20,7 @@ import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { createCustomViteLogger } from './errors/dev/index.js';
+import astroScannerPlugin from '../vite-plugin-scanner/index.js';
import { resolveDependency } from './util.js';
interface CreateViteOptions {
@@ -114,6 +115,7 @@ export async function createVite(
astroIntegrationsContainerPlugin({ settings, logging }),
astroScriptsPageSSRPlugin({ settings }),
astroHeadPropagationPlugin({ settings }),
+ settings.config.experimental.prerender && astroScannerPlugin({ settings, logging }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index 62ccbfd68..fb219d876 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -393,6 +393,22 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
`Could not render \`${componentName}\`. No matching import has been found for \`${componentName}\`.`,
hint: 'Please make sure the component is properly imported.',
},
+ /**
+ * @docs
+ * @description
+ * A `prerender` export was detected, but the value was not statically analyzable. Values computed at runtime are not supported, so `export const prerender` can only be set to `true` or `false`. Variables are not supported.
+ */
+ InvalidPrerenderExport: {
+ title: 'Invalid prerender export.',
+ code: 3019,
+ message: (prefix: string, suffix: string) => {
+ let msg = `A \`prerender\` export has been detected, but its value cannot be statically analyzed.`;
+ if (prefix !== 'const') msg += `\nExpected \`const\` declaration but got \`${prefix}\`.`
+ if (suffix !== 'true') msg += `\nExpected \`true\` value but got \`${suffix}\`.`
+ return msg;
+ },
+ hint: 'Mutable values declared at runtime are not supported. Please make sure to use exactly `export const prerender = true`.',
+ },
// Vite Errors - 4xxx
UnknownViteError: {
title: 'Unknown Vite Error.',
diff --git a/packages/astro/src/core/errors/overlay.ts b/packages/astro/src/core/errors/overlay.ts
index 431657292..464255a97 100644
--- a/packages/astro/src/core/errors/overlay.ts
+++ b/packages/astro/src/core/errors/overlay.ts
@@ -561,7 +561,7 @@ function getOverlayCode() {
}
export function patchOverlay(code: string, config: AstroConfig) {
- if (config.experimentalErrorOverlay) {
+ if (config.experimental.errorOverlay) {
return code.replace('class ErrorOverlay', getOverlayCode() + '\nclass ViteErrorOverlay');
} else {
// Legacy overlay
diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts
index bc4987926..cb742a5aa 100644
--- a/packages/astro/src/core/routing/match.ts
+++ b/packages/astro/src/core/routing/match.ts
@@ -5,6 +5,19 @@ export function matchRoute(pathname: string, manifest: ManifestData): RouteData
return manifest.routes.find((route) => route.pattern.test(pathname));
}
+/** Find matching static asset from pathname */
+export function matchAssets(route: RouteData, assets: Set<string>): string | undefined {
+ for (const asset of assets) {
+ if (!asset.endsWith('.html')) continue;
+ if (route.pattern.test(asset)) {
+ return asset;
+ }
+ if (route.pattern.test(asset.replace(/index\.html$/, ''))) {
+ return asset;
+ }
+ }
+}
+
/** Finds all matching routes from pathname */
export function matchAllRoutes(pathname: string, manifest: ManifestData): RouteData[] {
return manifest.routes.filter((route) => route.pattern.test(pathname));
diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts
index 01ed44237..d6f95062a 100644
--- a/packages/astro/src/core/util.ts
+++ b/packages/astro/src/core/util.ts
@@ -140,6 +140,12 @@ export function isPage(file: URL, settings: AstroSettings): boolean {
return endsWithPageExt(file, settings);
}
+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);
+}
+
export function isModeServerWithNoAdapter(settings: AstroSettings): boolean {
return settings.config.output === 'server' && !settings.adapter;
}
diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts
index 7e826d48d..1769cf90b 100644
--- a/packages/astro/src/integrations/index.ts
+++ b/packages/astro/src/integrations/index.ts
@@ -1,3 +1,4 @@
+import fs from 'node:fs';
import { bold } from 'kleur/colors';
import type { AddressInfo } from 'net';
import { fileURLToPath } from 'node:url';
@@ -345,6 +346,7 @@ export async function runHookBuildDone({
logging: LogOptions;
}) {
const dir = config.output === 'server' ? buildConfig.client : config.outDir;
+ await fs.promises.mkdir(dir, { recursive: true });
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:done']) {
diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts
index 8e8df454a..88b01ad8d 100644
--- a/packages/astro/src/jsx/babel.ts
+++ b/packages/astro/src/jsx/babel.ts
@@ -146,6 +146,7 @@ export default function astroJSX(): PluginObj {
hydratedComponents: [],
scripts: [],
propagation: 'none',
+ pageOptions: {},
};
}
path.node.body.splice(
diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts
index 0ecf91268..80b1aca82 100644
--- a/packages/astro/src/vite-plugin-astro/index.ts
+++ b/packages/astro/src/vite-plugin-astro/index.ts
@@ -220,6 +220,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none',
+ pageOptions: {},
};
return {
diff --git a/packages/astro/src/vite-plugin-astro/types.ts b/packages/astro/src/vite-plugin-astro/types.ts
index c9ac8332c..ebb1c7d39 100644
--- a/packages/astro/src/vite-plugin-astro/types.ts
+++ b/packages/astro/src/vite-plugin-astro/types.ts
@@ -1,11 +1,16 @@
import type { TransformResult } from '@astrojs/compiler';
import type { PropagationHint } from '../@types/astro';
+export interface PageOptions {
+ prerender?: boolean;
+}
+
export interface PluginMetadata {
astro: {
hydratedComponents: TransformResult['hydratedComponents'];
clientOnlyComponents: TransformResult['clientOnlyComponents'];
scripts: TransformResult['scripts'];
propagation: PropagationHint;
+ pageOptions: PageOptions;
};
}
diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts
index e0d3f4d62..b72418bdb 100644
--- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts
+++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts
@@ -234,6 +234,7 @@ ${tsResult}`;
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none',
+ pageOptions: {},
};
return {
diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts
index 46d84fffc..17d459570 100644
--- a/packages/astro/src/vite-plugin-markdown/index.ts
+++ b/packages/astro/src/vite-plugin-markdown/index.ts
@@ -158,6 +158,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
clientOnlyComponents: [],
scripts: [],
propagation: 'none',
+ pageOptions: {},
} as PluginMetadata['astro'],
vite: {
lang: 'ts',
diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts
new file mode 100644
index 000000000..1213652a1
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scanner/index.ts
@@ -0,0 +1,44 @@
+import { Plugin as VitePlugin } from 'vite';
+import { AstroSettings } from '../@types/astro.js';
+import { isPage, isEndpoint } from '../core/util.js';
+import type { LogOptions } from '../core/logger/core.js';
+import { normalizeFilename } from '../vite-plugin-utils/index.js';
+
+import { scan } from './scan.js';
+
+export default function astroScannerPlugin({ settings, logging }: { settings: AstroSettings, logging: LogOptions }): VitePlugin {
+ return {
+ name: 'astro:scanner',
+ enforce: 'post',
+
+ async transform(this, code, id, options) {
+ if (!options?.ssr) return;
+
+ const filename = normalizeFilename(id, settings.config);
+ let fileURL: URL;
+ try {
+ fileURL = new URL(`file://${filename}`);
+ } catch (e) {
+ // If we can't construct a valid URL, exit early
+ return;
+ }
+
+ const fileIsPage = isPage(fileURL, settings);
+ const fileIsEndpoint = isEndpoint(fileURL, settings);
+ if (!(fileIsPage || fileIsEndpoint)) return;
+ const pageOptions = await scan(code, id)
+
+ const { meta = {} } = this.getModuleInfo(id) ?? {};
+ return {
+ code,
+ meta: {
+ ...meta,
+ astro: {
+ ...(meta.astro ?? { hydratedComponents: [], clientOnlyComponents: [], scripts: [] }),
+ pageOptions,
+ },
+ },
+ };
+ },
+ };
+}
diff --git a/packages/astro/src/vite-plugin-scanner/scan.ts b/packages/astro/src/vite-plugin-scanner/scan.ts
new file mode 100644
index 000000000..51529e904
--- /dev/null
+++ b/packages/astro/src/vite-plugin-scanner/scan.ts
@@ -0,0 +1,48 @@
+import * as eslexer from 'es-module-lexer';
+import { PageOptions } from '../vite-plugin-astro/types.js';
+import { AstroError, AstroErrorCodes, AstroErrorData } from '../core/errors/index.js'
+
+const BOOLEAN_EXPORTS = new Set(['prerender']);
+
+// Quick scan to determine if code includes recognized export
+// False positives are not a problem, so be forgiving!
+function includesExport(code: string) {
+ for (const name of BOOLEAN_EXPORTS) {
+ if (code.includes(name)) return true;
+ }
+ return false;
+}
+
+let didInit = false;
+
+export async function scan(code: string, id: string): Promise<PageOptions> {
+ if (!includesExport(code)) return {};
+ if (!didInit) {
+ await eslexer.init;
+ didInit = true;
+ }
+
+ const [_, exports] = eslexer.parse(code, id);
+ let pageOptions: PageOptions = {};
+ for (const _export of exports) {
+ const { n: name, le: endOfLocalName } = _export;
+ if (BOOLEAN_EXPORTS.has(name)) {
+ // For a given export, check the value of the local declaration
+ // Basically extract the `const` from the statement `export const prerender = true`
+ const prefix = code.slice(0, endOfLocalName).split('export').pop()!.trim().replace('prerender', '').trim();
+ // For a given export, check the value of the first non-whitespace token.
+ // Basically extract the `true` from the statement `export const prerender = true`
+ const suffix = code.slice(endOfLocalName).trim().replace(/\=/, '').trim().split(/[;\n]/)[0];
+ if (prefix !== 'const' || !(suffix === 'true' || suffix === 'false')) {
+ throw new AstroError({
+ ...AstroErrorData.InvalidPrerenderExport,
+ message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix),
+ location: { file: id }
+ });
+ } else {
+ pageOptions[name as keyof PageOptions] = suffix === 'true';
+ }
+ }
+ }
+ return pageOptions;
+}
diff --git a/packages/astro/test/fixtures/ssr-prerender/package.json b/packages/astro/test/fixtures/ssr-prerender/package.json
new file mode 100644
index 000000000..28c9554db
--- /dev/null
+++ b/packages/astro/test/fixtures/ssr-prerender/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/ssr-prerender",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro b/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro
new file mode 100644
index 000000000..54680cfcb
--- /dev/null
+++ b/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro
@@ -0,0 +1,18 @@
+---
+export const prerender = true;
+
+const { searchParams } = Astro.url;
+---
+
+<html>
+<head>
+ <title>Static Page</title>
+ <script>
+ console.log('hello world');
+ </script>
+</head>
+ <body>
+ <h1 id="greeting">Hello world!</h1>
+ <div id="searchparams">{searchParams.get('q')}</div>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro b/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro
new file mode 100644
index 000000000..7f616356e
--- /dev/null
+++ b/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro
@@ -0,0 +1,12 @@
+---
+const { id } = Astro.params;
+---
+<html>
+ <head>
+ <title>Testing</title>
+ </head>
+ <body>
+ <h1>Testing</h1>
+ <h2 class="user">{ id }</h2>
+ </body>
+</html>
diff --git a/packages/astro/test/ssr-prerender.test.js b/packages/astro/test/ssr-prerender.test.js
new file mode 100644
index 000000000..6e5d854b6
--- /dev/null
+++ b/packages/astro/test/ssr-prerender.test.js
@@ -0,0 +1,52 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import { loadFixture } from './test-utils.js';
+import testAdapter from './test-adapter.js';
+
+describe('SSR: prerender', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/ssr-prerender/',
+ output: 'server',
+ adapter: testAdapter(),
+ experimental: {
+ prerender: true,
+ },
+ });
+ await fixture.build();
+ });
+
+ describe('Prerendering', () => {
+ // Prerendered assets are not served directly by `app`,
+ // they're served _in front of_ the app as static assets!
+ it('Does not render static page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/static');
+ const response = await app.render(request);
+ expect(response.status).to.equal(404);
+ });
+
+ it('includes prerendered pages in the asset manifest', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ /** @type {Set<string>} */
+ const assets = app.manifest.assets;
+ expect(assets.size).to.equal(1);
+ expect(Array.from(assets)[0].endsWith('static/index.html')).to.be.true;
+ });
+ });
+
+ describe('Astro.params in SSR', () => {
+ it('Params are passed to component', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/users/houston');
+ const response = await app.render(request);
+ expect(response.status).to.equal(200);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+ expect($('.user').text()).to.equal('houston');
+ });
+ });
+});
diff --git a/packages/astro/test/units/vite-plugin-astro/compile.test.js b/packages/astro/test/units/vite-plugin-astro/compile.test.js
index 49fedb3ed..db3f4d666 100644
--- a/packages/astro/test/units/vite-plugin-astro/compile.test.js
+++ b/packages/astro/test/units/vite-plugin-astro/compile.test.js
@@ -70,8 +70,9 @@ const name = 'world
const result = await compile(`<h1>Hello World</h1>`, '/src/components/index.astro');
await init;
const [, exports] = parse(result.code);
- expect(exports).to.include('default');
- expect(exports).to.include('file');
- expect(exports).to.include('url');
+ const names = exports.map(e => e.n);
+ expect(names).to.include('default');
+ expect(names).to.include('file');
+ expect(names).to.include('url');
});
});
diff --git a/packages/astro/test/units/vite-plugin-scanner/scan.test.js b/packages/astro/test/units/vite-plugin-scanner/scan.test.js
new file mode 100644
index 000000000..f27286da4
--- /dev/null
+++ b/packages/astro/test/units/vite-plugin-scanner/scan.test.js
@@ -0,0 +1,79 @@
+import { expect } from 'chai';
+import { scan } from '../../../dist/vite-plugin-scanner/scan.js';
+
+describe('astro scan', () => {
+ it('should return empty object', async () => {
+ const result = await scan(`export {}`, '/src/components/index.astro');
+ expect(Object.keys(result).length).to.equal(0);
+ });
+
+ it('recognizes constant boolean literal (false)', async () => {
+ const result = await scan(`export const prerender = true;`, '/src/components/index.astro');
+ expect(result.prerender).to.equal(true);
+ });
+
+ it('recognizes constant boolean literal (false)', async () => {
+ const result = await scan(`export const prerender = false;`, '/src/components/index.astro');
+ expect(result.prerender).to.equal(false);
+ });
+
+ it('throws on let boolean literal', async () => {
+ try {
+ const result = await scan(`export let prerender = true;`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+
+ it('throws on var boolean literal', async () => {
+ try {
+ const result = await scan(`export var prerender = true;`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+
+ it('throws on unknown values I', async () => {
+ try {
+ const result = await scan(`export const prerender = !!value;`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+
+ it('throws on unknown values II', async () => {
+ try {
+ const result = await scan(`export const prerender = value;`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+
+ it('throws on unknown values III', async () => {
+ try {
+ const result = await scan(`export let prerender = undefined; prerender = true;`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+
+ it('throws on unknown values IV', async () => {
+ try {
+ const result = await scan(`let prerender = true; export { prerender }`, '/src/components/index.astro');
+ expect(false).to.be.true;
+ } catch (e) {
+ expect(e.errorCode).to.equal(3019);
+ expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`);
+ }
+ });
+});
diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts
index c0d8a4ce5..2c648984a 100644
--- a/packages/integrations/netlify/src/shared.ts
+++ b/packages/integrations/netlify/src/shared.ts
@@ -14,18 +14,28 @@ export async function createRedirects(
let _redirects = '';
for (const route of routes) {
if (route.pathname) {
- _redirects += `
+ if (route.distURL) {
+ _redirects += `
+ ${route.pathname} /${route.distURL.toString().replace(dir.toString(), '')} 200`;
+ } else {
+ _redirects += `
${route.pathname} /.netlify/${kind}/${entryFile} 200`;
- if (route.route === '/404') {
- _redirects += `
+ if (route.route === '/404') {
+ _redirects += `
/* /.netlify/${kind}/${entryFile} 404`;
+ }
}
} else {
const pattern =
'/' + route.segments.map(([part]) => (part.dynamic ? '*' : part.content)).join('/');
- _redirects += `
+ if (route.distURL) {
+ _redirects += `
+ ${pattern} /${route.distURL.toString().replace(dir.toString(), '')} 200`;
+ } else {
+ _redirects += `
${pattern} /.netlify/${kind}/${entryFile} 200`;
+ }
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 31afd68d4..8e3446dc0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -422,7 +422,7 @@ importers:
deepmerge-ts: ^4.2.2
diff: ^5.1.0
eol: ^0.9.1
- es-module-lexer: ^0.10.5
+ es-module-lexer: ^1.1.0
execa: ^6.1.0
fast-glob: ^3.2.11
github-slugger: ^1.4.0
@@ -494,7 +494,7 @@ importers:
debug: 4.3.4
deepmerge-ts: 4.2.2
diff: 5.1.0
- es-module-lexer: 0.10.5
+ es-module-lexer: 1.1.0
execa: 6.1.0
fast-glob: 3.2.12
github-slugger: 1.5.0
@@ -1115,6 +1115,9 @@ importers:
'@astrojs/node': link:../../../../integrations/node
astro: link:../../..
+ packages/astro/test/benchmark/simple/dist/server:
+ specifiers: {}
+
packages/astro/test/fixtures/0-css:
specifiers:
'@astrojs/react': workspace:*
@@ -1655,6 +1658,9 @@ importers:
dependencies:
astro: link:../../..
+ packages/astro/test/fixtures/config-vite/dist:
+ specifiers: {}
+
packages/astro/test/fixtures/css-assets:
specifiers:
'@astrojs/test-font-awesome-package': file:packages/font-awesome
@@ -2308,6 +2314,12 @@ importers:
'@astrojs/partytown': link:../../../../integrations/partytown
astro: link:../../..
+ packages/astro/test/fixtures/ssr-prerender:
+ specifiers:
+ astro: workspace:*
+ dependencies:
+ astro: link:../../..
+
packages/astro/test/fixtures/ssr-preview:
specifiers:
astro: workspace:*
@@ -11819,6 +11831,10 @@ packages:
resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==}
dev: false
+ /es-module-lexer/1.1.0:
+ resolution: {integrity: sha512-fJg+1tiyEeS8figV+fPcPpm8WqJEflG3yPU0NOm5xMvrNkuiy7HzX/Ljng4Y0hAoiw4/3hQTCFYw+ub8+a2pRA==}
+ dev: false
+
/es-shim-unscopables/1.0.0:
resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==}
dependencies: