summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Nate Moore <natemoo-re@users.noreply.github.com> 2023-11-09 10:55:29 -0500
committerGravatar GitHub <noreply@github.com> 2023-11-09 09:55:29 -0600
commit3e1239e42b99bf069265393dc359bf967fc64902 (patch)
tree674357f8282e28af77c72c348deceda670918f28
parent5b16619e4ad9bbbf7a1519899b7f5dd1b850562a (diff)
downloadastro-3e1239e42b99bf069265393dc359bf967fc64902.tar.gz
astro-3e1239e42b99bf069265393dc359bf967fc64902.tar.zst
astro-3e1239e42b99bf069265393dc359bf967fc64902.zip
Content Collection cache (experimental) (#8854)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Matthew Phillips <matthew@skypack.dev>
-rw-r--r--.changeset/lovely-pianos-build.md28
-rw-r--r--packages/astro/content-module.template.mjs18
-rw-r--r--packages/astro/src/@types/astro.ts18
-rw-r--r--packages/astro/src/assets/vite-plugin-assets.ts3
-rw-r--r--packages/astro/src/cli/build/index.ts2
-rw-r--r--packages/astro/src/content/consts.ts1
-rw-r--r--packages/astro/src/content/runtime.ts7
-rw-r--r--packages/astro/src/content/utils.ts9
-rw-r--r--packages/astro/src/content/vite-plugin-content-assets.ts78
-rw-r--r--packages/astro/src/content/vite-plugin-content-virtual-mod.ts218
-rw-r--r--packages/astro/src/core/build/buildPipeline.ts9
-rw-r--r--packages/astro/src/core/build/index.ts17
-rw-r--r--packages/astro/src/core/build/internal.ts9
-rw-r--r--packages/astro/src/core/build/plugin.ts53
-rw-r--r--packages/astro/src/core/build/plugins/index.ts4
-rw-r--r--packages/astro/src/core/build/plugins/plugin-alias-resolve.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-analyzer.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-chunks.ts33
-rw-r--r--packages/astro/src/core/build/plugins/plugin-component-entry.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-content.ts302
-rw-r--r--packages/astro/src/core/build/plugins/plugin-css.ts20
-rw-r--r--packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-internals.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-manifest.ts4
-rw-r--r--packages/astro/src/core/build/plugins/plugin-middleware.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-pages.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-prerender.ts10
-rw-r--r--packages/astro/src/core/build/plugins/plugin-renderers.ts2
-rw-r--r--packages/astro/src/core/build/plugins/plugin-ssr.ts4
-rw-r--r--packages/astro/src/core/build/plugins/util.ts6
-rw-r--r--packages/astro/src/core/build/static-build.ts72
-rw-r--r--packages/astro/src/core/build/util.ts16
-rw-r--r--packages/astro/src/core/config/config.ts5
-rw-r--r--packages/astro/src/core/config/schema.ts2
-rw-r--r--packages/astro/src/core/create-vite.ts2
-rw-r--r--packages/astro/src/runtime/server/render/component.ts9
-rw-r--r--packages/astro/src/vite-plugin-head/index.ts2
-rw-r--r--packages/astro/test/experimental-content-collection-references.test.js160
-rw-r--r--packages/astro/test/experimental-content-collections-css-inline-stylesheets.test.js342
-rw-r--r--packages/astro/test/experimental-content-collections-render.test.js288
-rw-r--r--packages/astro/test/experimental-content-collections.test.js372
-rw-r--r--packages/astro/test/fixtures/content-collection-references/src/pages/welcome-data.json.js14
-rw-r--r--packages/astro/test/test-utils.js8
-rw-r--r--packages/astro/test/units/test-utils.js8
-rw-r--r--scripts/cmd/copy.js2
45 files changed, 1962 insertions, 209 deletions
diff --git a/.changeset/lovely-pianos-build.md b/.changeset/lovely-pianos-build.md
new file mode 100644
index 000000000..d7d512f3c
--- /dev/null
+++ b/.changeset/lovely-pianos-build.md
@@ -0,0 +1,28 @@
+---
+'astro': minor
+---
+
+Provides a new, experimental build cache for [Content Collections](https://docs.astro.build/en/guides/content-collections/) as part of the [Incremental Build RFC](https://github.com/withastro/roadmap/pull/763). This includes multiple refactors to Astro's build process to optimize how Content Collections are handled, which should provide significant performance improvements for users with many collections.
+
+Users building a `static` site can opt-in to preview the new build cache by adding the following flag to your Astro config:
+
+```js
+// astro.config.mjs
+export default {
+ experimental: {
+ contentCollectionCache: true,
+ },
+};
+```
+
+When this experimental feature is enabled, the files generated from your content collections will be stored in the [`cacheDir`](https://docs.astro.build/en/reference/configuration-reference/#cachedir) (by default, `node_modules/.astro`) and reused between builds. Most CI environments automatically restore files in `node_modules/` by default.
+
+In our internal testing on the real world [Astro Docs](https://github.com/withastro/docs) project, this feature reduces the bundling step of `astro build` from **133.20s** to **10.46s**, about 92% faster. The end-to-end `astro build` process used to take **4min 58s** and now takes just over `1min` for a total reduction of 80%.
+
+If you run into any issues with this experimental feature, please let us know!
+
+You can always bypass the cache for a single build by passing the `--force` flag to `astro build`.
+
+```
+astro build --force
+```
diff --git a/packages/astro/content-module.template.mjs b/packages/astro/content-module.template.mjs
index 9ce06960f..137e44252 100644
--- a/packages/astro/content-module.template.mjs
+++ b/packages/astro/content-module.template.mjs
@@ -9,21 +9,18 @@ import {
createReference,
} from 'astro/content/runtime';
+export { defineCollection } from 'astro/content/runtime';
export { z } from 'astro/zod';
const contentDir = '@@CONTENT_DIR@@';
-const contentEntryGlob = import.meta.glob('@@CONTENT_ENTRY_GLOB_PATH@@', {
- query: { astroContentCollectionEntry: true },
-});
+const contentEntryGlob = '@@CONTENT_ENTRY_GLOB_PATH@@';
const contentCollectionToEntryMap = createCollectionToGlobResultMap({
globResult: contentEntryGlob,
contentDir,
});
-const dataEntryGlob = import.meta.glob('@@DATA_ENTRY_GLOB_PATH@@', {
- query: { astroDataCollectionEntry: true },
-});
+const dataEntryGlob = '@@DATA_ENTRY_GLOB_PATH@@';
const dataCollectionToEntryMap = createCollectionToGlobResultMap({
globResult: dataEntryGlob,
contentDir,
@@ -45,19 +42,12 @@ function createGlobLookup(glob) {
};
}
-const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
- query: { astroRenderContent: true },
-});
+const renderEntryGlob = '@@RENDER_ENTRY_GLOB_PATH@@'
const collectionToRenderEntryMap = createCollectionToGlobResultMap({
globResult: renderEntryGlob,
contentDir,
});
-export function defineCollection(config) {
- if (!config.type) config.type = 'content';
- return config;
-}
-
export const getCollection = createGetCollection({
contentCollectionToEntryMap,
dataCollectionToEntryMap,
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 597043d49..9aa7dc89b 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -1527,6 +1527,24 @@ export interface AstroUserConfig {
*/
routingStrategy?: 'prefix-always' | 'prefix-other-locales';
};
+ /**
+ * @docs
+ * @name experimental.contentCollectionCache
+ * @type {boolean}
+ * @default `false`
+ * @version 3.5.0
+ * @description
+ * Enables a persistent cache for content collections when building in static mode.
+ *
+ * ```js
+ * {
+ * experimental: {
+ * contentCollectionCache: true,
+ * },
+ * }
+ * ```
+ */
+ contentCollectionCache?: boolean;
};
}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
index 5d3951e24..02e3a3bb3 100644
--- a/packages/astro/src/assets/vite-plugin-assets.ts
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -42,7 +42,8 @@ export default function assets({
extendManualChunks(outputOptions, {
after(id) {
if (id.includes('astro/dist/assets/services/')) {
- return `astro-assets-services`;
+ // By convention, library code is emitted to the `chunks/astro/*` directory
+ return `astro/assets-service`;
}
},
});
diff --git a/packages/astro/src/cli/build/index.ts b/packages/astro/src/cli/build/index.ts
index 8919dfc40..1a7d5aa52 100644
--- a/packages/astro/src/cli/build/index.ts
+++ b/packages/astro/src/cli/build/index.ts
@@ -26,5 +26,5 @@ export async function build({ flags }: BuildOptions) {
const inlineConfig = flagsToAstroInlineConfig(flags);
- await _build(inlineConfig);
+ await _build(inlineConfig, { force: flags.force ?? false });
}
diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts
index 9bfb2e865..f65652453 100644
--- a/packages/astro/src/content/consts.ts
+++ b/packages/astro/src/content/consts.ts
@@ -4,6 +4,7 @@ export const CONTENT_FLAG = 'astroContentCollectionEntry';
export const DATA_FLAG = 'astroDataCollectionEntry';
export const VIRTUAL_MODULE_ID = 'astro:content';
+export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
export const SCRIPTS_PLACEHOLDER = '@@ASTRO-SCRIPTS@@';
diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts
index e507149fe..5ca396da8 100644
--- a/packages/astro/src/content/runtime.ts
+++ b/packages/astro/src/content/runtime.ts
@@ -19,6 +19,11 @@ type GlobResult = Record<string, LazyImport>;
type CollectionToEntryMap = Record<string, GlobResult>;
type GetEntryImport = (collection: string, lookupId: string) => Promise<LazyImport>;
+export function defineCollection(config: any) {
+ if (!config.type) config.type = 'content';
+ return config;
+}
+
export function createCollectionToGlobResultMap({
globResult,
contentDir,
@@ -69,7 +74,7 @@ export function createGetCollection({
let entries: any[] = [];
// Cache `getCollection()` calls in production only
// prevents stale cache in development
- if (import.meta.env.PROD && cacheEntriesByCollection.has(collection)) {
+ if (!import.meta.env?.DEV && cacheEntriesByCollection.has(collection)) {
// Always return a new instance so consumers can safely mutate it
entries = [...cacheEntriesByCollection.get(collection)!];
} else {
diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts
index 5834c81d8..e95e167c8 100644
--- a/packages/astro/src/content/utils.ts
+++ b/packages/astro/src/content/utils.ts
@@ -326,6 +326,15 @@ export function parseFrontmatter(fileContents: string) {
*/
export const globalContentConfigObserver = contentObservable({ status: 'init' });
+export function hasAnyContentFlag(viteId: string): boolean {
+ const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
+ const flag = Array.from(flags.keys()).at(0);
+ if (typeof flag !== 'string') {
+ return false;
+ }
+ return CONTENT_FLAGS.includes(flag as any);
+}
+
export function hasContentFlag(viteId: string, flag: (typeof CONTENT_FLAGS)[number]): boolean {
const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
return flags.has(flag);
diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts
index 133fc9edd..8dafc1be8 100644
--- a/packages/astro/src/content/vite-plugin-content-assets.ts
+++ b/packages/astro/src/content/vite-plugin-content-assets.ts
@@ -1,6 +1,6 @@
import { extname } from 'node:path';
import { pathToFileURL } from 'node:url';
-import type { Plugin } from 'vite';
+import type { Plugin, Rollup } from 'vite';
import type { AstroSettings } from '../@types/astro.js';
import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js';
import { getPageDataByViteID, type BuildInternals } from '../core/build/internal.js';
@@ -110,16 +110,16 @@ export function astroConfigBuildPlugin(
options: StaticBuildOptions,
internals: BuildInternals
): AstroBuildPlugin {
- let ssrPluginContext: any = undefined;
+ let ssrPluginContext: Rollup.PluginContext | undefined = undefined;
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
- 'build:before': ({ build }) => {
+ 'build:before': ({ target }) => {
return {
vitePlugin: {
name: 'astro:content-build-plugin',
generateBundle() {
- if (build === 'ssr') {
+ if (target === 'server') {
ssrPluginContext = this;
}
},
@@ -144,24 +144,43 @@ export function astroConfigBuildPlugin(
let entryLinks = new Set<string>();
let entryScripts = new Set<string>();
- for (const id of Object.keys(chunk.modules)) {
- for (const [pageInfo] of walkParentInfos(id, ssrPluginContext)) {
- if (moduleIsTopLevelPage(pageInfo)) {
- const pageViteID = pageInfo.id;
- const pageData = getPageDataByViteID(internals, pageViteID);
- if (!pageData) continue;
+ if (options.settings.config.experimental.contentCollectionCache) {
+ // TODO: hoisted scripts are still handled on the pageData rather than the asset propagation point
+ for (const id of chunk.moduleIds) {
+ const _entryCss = internals.propagatedStylesMap.get(id);
+ const _entryScripts = internals.propagatedScriptsMap.get(id);
+ if (_entryCss) {
+ for (const value of _entryCss) {
+ if (value.type === 'inline') entryStyles.add(value.content);
+ if (value.type === 'external') entryLinks.add(value.src);
+ }
+ }
+ if (_entryScripts) {
+ for (const value of _entryScripts) {
+ entryScripts.add(value);
+ }
+ }
+ }
+ } else {
+ for (const id of Object.keys(chunk.modules)) {
+ for (const [pageInfo] of walkParentInfos(id, ssrPluginContext!)) {
+ if (moduleIsTopLevelPage(pageInfo)) {
+ const pageViteID = pageInfo.id;
+ const pageData = getPageDataByViteID(internals, pageViteID);
+ if (!pageData) continue;
- const _entryCss = pageData.propagatedStyles?.get(id);
- const _entryScripts = pageData.propagatedScripts?.get(id);
- if (_entryCss) {
- for (const value of _entryCss) {
- if (value.type === 'inline') entryStyles.add(value.content);
- if (value.type === 'external') entryLinks.add(value.src);
+ const _entryCss = internals.propagatedStylesMap?.get(id);
+ const _entryScripts = pageData.propagatedScripts?.get(id);
+ if (_entryCss) {
+ for (const value of _entryCss) {
+ if (value.type === 'inline') entryStyles.add(value.content);
+ if (value.type === 'external') entryLinks.add(value.src);
+ }
}
- }
- if (_entryScripts) {
- for (const value of _entryScripts) {
- entryScripts.add(value);
+ if (_entryScripts) {
+ for (const value of _entryScripts) {
+ entryScripts.add(value);
+ }
}
}
}
@@ -174,12 +193,22 @@ export function astroConfigBuildPlugin(
JSON.stringify(STYLES_PLACEHOLDER),
JSON.stringify(Array.from(entryStyles))
);
+ } else {
+ newCode = newCode.replace(
+ JSON.stringify(STYLES_PLACEHOLDER),
+ "[]"
+ );
}
if (entryLinks.size) {
newCode = newCode.replace(
JSON.stringify(LINKS_PLACEHOLDER),
JSON.stringify(Array.from(entryLinks).map(prependBase))
);
+ } else {
+ newCode = newCode.replace(
+ JSON.stringify(LINKS_PLACEHOLDER),
+ "[]"
+ );
}
if (entryScripts.size) {
const entryFileNames = new Set<string>();
@@ -205,8 +234,13 @@ export function astroConfigBuildPlugin(
}))
)
);
+ } else {
+ newCode = newCode.replace(
+ JSON.stringify(SCRIPTS_PLACEHOLDER),
+ "[]"
+ );
}
- mutate(chunk, 'server', newCode);
+ mutate(chunk, ['server'], newCode);
}
}
},
diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
index d7924973c..1f4f7eead 100644
--- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
+++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
@@ -1,14 +1,15 @@
import glob from 'fast-glob';
-import fsMod from 'node:fs';
+import nodeFs from 'node:fs';
import { extname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pLimit from 'p-limit';
-import type { Plugin } from 'vite';
-import type { AstroSettings, ContentEntryType } from '../@types/astro.js';
+import { type Plugin } from 'vite';
+import type { AstroSettings } from '../@types/astro.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
-import { appendForwardSlash } from '../core/path.js';
+import { appendForwardSlash, removeFileExtension } from '../core/path.js';
import { rootRelativePath } from '../core/util.js';
-import { VIRTUAL_MODULE_ID } from './consts.js';
+import { encodeName } from '../core/build/util.js';
+import { CONTENT_FLAG, CONTENT_RENDER_FLAG, DATA_FLAG, VIRTUAL_MODULE_ID, RESOLVED_VIRTUAL_MODULE_ID } from './consts.js';
import {
getContentEntryIdAndSlug,
getContentPaths,
@@ -20,76 +21,154 @@ import {
getEntryType,
getExtGlob,
type ContentLookupMap,
- type ContentPaths,
} from './utils.js';
+import type { AstroPluginMetadata } from '../vite-plugin-astro/index.js';
+import { isServerLikeOutput } from '../prerender/utils.js';
interface AstroContentVirtualModPluginParams {
settings: AstroSettings;
+ fs: typeof nodeFs
}
export function astroContentVirtualModPlugin({
settings,
+ fs,
}: AstroContentVirtualModPluginParams): Plugin {
+ let IS_DEV = false;
+ const IS_SERVER = isServerLikeOutput(settings.config);
+ return {
+ name: 'astro-content-virtual-mod-plugin',
+ enforce: 'pre',
+ configResolved(config) {
+ IS_DEV = config.mode === 'development'
+ },
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ if (!settings.config.experimental.contentCollectionCache) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ if (IS_DEV || IS_SERVER) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ } else {
+ // For SSG (production), we will build this file ourselves
+ return { id: RESOLVED_VIRTUAL_MODULE_ID, external: true }
+ }
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ const lookupMap = await generateLookupMap({
+ settings,
+ fs,
+ });
+ const code = await generateContentEntryFile({ settings, fs, lookupMap, IS_DEV, IS_SERVER });
+
+ return {
+ code,
+ meta: {
+ astro: {
+ hydratedComponents: [],
+ clientOnlyComponents: [],
+ scripts: [],
+ containsHead: true,
+ propagation: 'in-tree',
+ pageOptions: {}
+ }
+ } satisfies AstroPluginMetadata
+ };
+ }
+ },
+ renderChunk(code, chunk) {
+ if (!settings.config.experimental.contentCollectionCache) {
+ return;
+ }
+ if (code.includes(RESOLVED_VIRTUAL_MODULE_ID)) {
+ const depth = chunk.fileName.split('/').length - 1;
+ const prefix = depth > 0 ? '../'.repeat(depth) : './';
+ return code.replaceAll(RESOLVED_VIRTUAL_MODULE_ID, `${prefix}content/entry.mjs`);
+ }
+ }
+ };
+}
+
+export async function generateContentEntryFile({
+ settings,
+ lookupMap,
+ IS_DEV,
+ IS_SERVER
+}: {
+ settings: AstroSettings;
+ fs: typeof nodeFs;
+ lookupMap: ContentLookupMap
+ IS_DEV: boolean;
+ IS_SERVER: boolean;
+}) {
const contentPaths = getContentPaths(settings.config);
const relContentDir = rootRelativePath(settings.config.root, contentPaths.contentDir);
- const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
- const contentEntryExts = [...contentEntryConfigByExt.keys()];
- const dataEntryExts = getDataEntryExts(settings);
+ let contentEntryGlobResult: string;
+ let dataEntryGlobResult: string;
+ let renderEntryGlobResult: string;
+ if (IS_DEV || IS_SERVER || !settings.config.experimental.contentCollectionCache) {
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const contentEntryExts = [...contentEntryConfigByExt.keys()];
+ const dataEntryExts = getDataEntryExts(settings);
+ const createGlob = (value: string[], flag: string) => `import.meta.glob(${JSON.stringify(value)}, { query: { ${flag}: true } })`
+ contentEntryGlobResult = createGlob(globWithUnderscoresIgnored(relContentDir, contentEntryExts), CONTENT_FLAG);
+ dataEntryGlobResult = createGlob(globWithUnderscoresIgnored(relContentDir, dataEntryExts), DATA_FLAG);
+ renderEntryGlobResult = createGlob(globWithUnderscoresIgnored(relContentDir, contentEntryExts), CONTENT_RENDER_FLAG);
+ } else {
+ contentEntryGlobResult = getStringifiedCollectionFromLookup('content', relContentDir, lookupMap);
+ dataEntryGlobResult = getStringifiedCollectionFromLookup('data', relContentDir, lookupMap);
+ renderEntryGlobResult = getStringifiedCollectionFromLookup('render', relContentDir, lookupMap);
+ }
- const virtualModContents = fsMod
+ const virtualModContents = nodeFs
.readFileSync(contentPaths.virtualModTemplate, 'utf-8')
- .replace(
- '@@COLLECTION_NAME_BY_REFERENCE_KEY@@',
- new URL('reference-map.json', contentPaths.cacheDir).pathname
- )
.replace('@@CONTENT_DIR@@', relContentDir)
.replace(
"'@@CONTENT_ENTRY_GLOB_PATH@@'",
- JSON.stringify(globWithUnderscoresIgnored(relContentDir, contentEntryExts))
+ contentEntryGlobResult
)
.replace(
"'@@DATA_ENTRY_GLOB_PATH@@'",
- JSON.stringify(globWithUnderscoresIgnored(relContentDir, dataEntryExts))
+ dataEntryGlobResult
)
.replace(
"'@@RENDER_ENTRY_GLOB_PATH@@'",
- JSON.stringify(
- globWithUnderscoresIgnored(
- relContentDir,
- /** Note: data collections excluded */ contentEntryExts
- )
- )
+ renderEntryGlobResult
+ ).replace(
+ '/* @@LOOKUP_MAP_ASSIGNMENT@@ */',
+ `lookupMap = ${JSON.stringify(lookupMap)};`
);
- const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
-
- return {
- name: 'astro-content-virtual-mod-plugin',
- resolveId(id) {
- if (id === VIRTUAL_MODULE_ID) {
- return astroContentVirtualModuleId;
- }
- },
- async load(id) {
- if (id === astroContentVirtualModuleId) {
- const stringifiedLookupMap = await getStringifiedLookupMap({
- fs: fsMod,
- contentPaths,
- contentEntryConfigByExt,
- dataEntryExts,
- root: settings.config.root,
- });
+ return virtualModContents;
+}
- return {
- code: virtualModContents.replace(
- '/* @@LOOKUP_MAP_ASSIGNMENT@@ */',
- `lookupMap = ${stringifiedLookupMap};`
- ),
- };
+function getStringifiedCollectionFromLookup(wantedType: 'content' | 'data' | 'render', relContentDir: string, lookupMap: ContentLookupMap) {
+ let str = '{';
+ // In dev, we don't need to normalize the import specifier at all. Vite handles it.
+ let normalize = (slug: string) => slug;
+ // For prod builds, we need to transform from `/src/content/**/*.{md,mdx,json,yaml}` to a relative `./**/*.mjs` import
+ if (process.env.NODE_ENV === 'production') {
+ const suffix = wantedType === 'render' ? '.entry.mjs' : '.mjs';
+ normalize = (slug: string) => `${removeFileExtension(encodeName(slug)).replace(relContentDir, './')}${suffix}`
+ } else {
+ let suffix = '';
+ if (wantedType === 'content') suffix = CONTENT_FLAG;
+ else if (wantedType === 'data') suffix = DATA_FLAG;
+ else if (wantedType === 'render') suffix = CONTENT_RENDER_FLAG;
+ normalize = (slug: string) => `${slug}?${suffix}`
+ }
+ for (const { type, entries } of Object.values(lookupMap)) {
+ if (type === wantedType || wantedType === 'render' && type === 'content') {
+ for (const slug of Object.values(entries)) {
+ str += `\n "${slug}": () => import("${normalize(slug)}"),`
}
- },
- };
+ }
+ }
+ str += '\n}'
+ return str;
}
/**
@@ -97,21 +176,22 @@ export function astroContentVirtualModPlugin({
* This is used internally to resolve entry imports when using `getEntry()`.
* @see `content-module.template.mjs`
*/
-export async function getStringifiedLookupMap({
- contentPaths,
- contentEntryConfigByExt,
- dataEntryExts,
- root,
+export async function generateLookupMap({
+ settings,
fs,
}: {
- contentEntryConfigByExt: Map<string, ContentEntryType>;
- dataEntryExts: string[];
- contentPaths: Pick<ContentPaths, 'contentDir' | 'config'>;
- root: URL;
- fs: typeof fsMod;
+ settings: AstroSettings;
+ fs: typeof nodeFs;
}) {
+ const { root } = settings.config;
+ const contentPaths = getContentPaths(settings.config);
+ const relContentDir = rootRelativePath(root, contentPaths.contentDir, false);
+
+ const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes);
+ const dataEntryExts = getDataEntryExts(settings);
+
const { contentDir } = contentPaths;
- const relContentDir = rootRelativePath(root, contentDir, false);
+
const contentEntryExts = [...contentEntryConfigByExt.keys()];
let lookupMap: ContentLookupMap = {};
@@ -120,12 +200,9 @@ export async function getStringifiedLookupMap({
{
absolute: true,
cwd: fileURLToPath(root),
- fs: {
- readdir: fs.readdir.bind(fs),
- readdirSync: fs.readdirSync.bind(fs),
- },
+ fs,
}
- );
+ )
// Run 10 at a time to prevent `await getEntrySlug` from accessing the filesystem all at once.
// Each await shouldn't take too long for the work to be noticably slow too.
@@ -199,15 +276,9 @@ export async function getStringifiedLookupMap({
}
await Promise.all(promises);
-
- return JSON.stringify(lookupMap);
+ return lookupMap;
}
-const UnexpectedLookupMapError = new AstroError({
- ...AstroErrorData.UnknownContentCollectionError,
- message: `Unexpected error while parsing content entry IDs and slugs.`,
-});
-
function globWithUnderscoresIgnored(relContentDir: string, exts: string[]): string[] {
const extGlob = getExtGlob(exts);
const contentDir = appendForwardSlash(relContentDir);
@@ -217,3 +288,8 @@ function globWithUnderscoresIgnored(relContentDir: string, exts: string[]): stri
`!${contentDir}**/_*${extGlob}`,
];
}
+
+const UnexpectedLookupMapError = new AstroError({
+ ...AstroErrorData.UnknownContentCollectionError,
+ message: `Unexpected error while parsing content entry IDs and slugs.`,
+});
diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts
index 83e45f808..fc315ff7d 100644
--- a/packages/astro/src/core/build/buildPipeline.ts
+++ b/packages/astro/src/core/build/buildPipeline.ts
@@ -28,6 +28,7 @@ export class BuildPipeline extends Pipeline {
manifest: SSRManifest
) {
const ssr = isServerLikeOutput(staticBuildOptions.settings.config);
+ const resolveCache = new Map<string, string>();
super(
createEnvironment({
adapterName: manifest.adapterName,
@@ -37,16 +38,22 @@ export class BuildPipeline extends Pipeline {
clientDirectives: manifest.clientDirectives,
compressHTML: manifest.compressHTML,
async resolve(specifier: string) {
+ if (resolveCache.has(specifier)) {
+ return resolveCache.get(specifier)!;
+ }
const hashedFilePath = manifest.entryModules[specifier];
if (typeof hashedFilePath !== 'string' || hashedFilePath === '') {
// If no "astro:scripts/before-hydration.js" script exists in the build,
// then we can assume that no before-hydration scripts are needed.
if (specifier === BEFORE_HYDRATION_SCRIPT_ID) {
+ resolveCache.set(specifier, '');
return '';
}
throw new Error(`Cannot find the built path for ${specifier}`);
}
- return createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
+ const assetLink = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
+ resolveCache.set(specifier, assetLink);
+ return assetLink;
},
routeCache: staticBuildOptions.routeCache,
site: manifest.site,
diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts
index a6bcf8b17..0960760a4 100644
--- a/packages/astro/src/core/build/index.ts
+++ b/packages/astro/src/core/build/index.ts
@@ -42,6 +42,14 @@ export interface BuildOptions {
* @default true
*/
teardownCompiler?: boolean;
+
+ /**
+ * If `experimental.contentCollectionCache` is enabled, this flag will clear the cache before building
+ *
+ * @internal not part of our public api
+ * @default false
+ */
+ force?: boolean;
}
/**
@@ -52,12 +60,19 @@ export interface BuildOptions {
*/
export default async function build(
inlineConfig: AstroInlineConfig,
- options?: BuildOptions
+ options: BuildOptions = {}
): Promise<void> {
applyPolyfill();
const logger = createNodeLogger(inlineConfig);
const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build');
telemetry.record(eventCliSession('build', userConfig));
+ if (astroConfig.experimental.contentCollectionCache && options.force) {
+ const contentCacheDir = new URL('./content/', astroConfig.cacheDir);
+ if (fs.existsSync(contentCacheDir)) {
+ logger.warn('content', 'clearing cache');
+ await fs.promises.rm(contentCacheDir, { force: true, recursive: true })
+ }
+ }
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts
index ce517485b..1dc38e735 100644
--- a/packages/astro/src/core/build/internal.ts
+++ b/packages/astro/src/core/build/internal.ts
@@ -89,6 +89,11 @@ export interface BuildInternals {
*/
discoveredScripts: Set<string>;
+ cachedClientEntries: string[];
+
+ propagatedStylesMap: Map<string, Set<StylesheetAsset>>;
+ propagatedScriptsMap: Map<string, Set<string>>;
+
// A list of all static files created during the build. Used for SSR.
staticFiles: Set<string>;
// The SSR entry chunk. Kept in internals to share between ssr/client build steps
@@ -114,6 +119,7 @@ export function createBuildInternals(): BuildInternals {
const hoistedScriptIdToPagesMap = new Map<string, Set<string>>();
return {
+ cachedClientEntries: [],
cssModuleToChunkIdMap: new Map(),
hoistedScriptIdToHoistedMap,
hoistedScriptIdToPagesMap,
@@ -125,6 +131,9 @@ export function createBuildInternals(): BuildInternals {
pagesByViteID: new Map(),
pagesByClientOnly: new Map(),
+ propagatedStylesMap: new Map(),
+ propagatedScriptsMap: new Map(),
+
discoveredHydratedComponents: new Map(),
discoveredClientOnlyComponents: new Map(),
discoveredScripts: new Set(),
diff --git a/packages/astro/src/core/build/plugin.ts b/packages/astro/src/core/build/plugin.ts
index c5da47457..ef1207e3f 100644
--- a/packages/astro/src/core/build/plugin.ts
+++ b/packages/astro/src/core/build/plugin.ts
@@ -5,16 +5,19 @@ import type { StaticBuildOptions, ViteBuildReturn } from './types.js';
type RollupOutputArray = Extract<ViteBuildReturn, Array<any>>;
type OutputChunkorAsset = RollupOutputArray[number]['output'][number];
type OutputChunk = Extract<OutputChunkorAsset, { type: 'chunk' }>;
+export type BuildTarget = 'server' | 'client';
-type MutateChunk = (chunk: OutputChunk, build: 'server' | 'client', newCode: string) => void;
+type MutateChunk = (chunk: OutputChunk, targets: BuildTarget[], newCode: string) => void;
+
+export interface BuildBeforeHookResult {
+ enforce?: 'after-user-plugins';
+ vitePlugin: VitePlugin | VitePlugin[] | undefined;
+}
export type AstroBuildPlugin = {
- build: 'ssr' | 'client' | 'both';
+ targets: BuildTarget[];
hooks?: {
- 'build:before'?: (opts: { build: 'ssr' | 'client'; input: Set<string> }) => {
- enforce?: 'after-user-plugins';
- vitePlugin: VitePlugin | VitePlugin[] | undefined;
- };
+ 'build:before'?: (opts: { target: BuildTarget; input: Set<string> }) => BuildBeforeHookResult | Promise<BuildBeforeHookResult>;
'build:post'?: (opts: {
ssrOutputs: RollupOutputArray;
clientOutputs: RollupOutputArray;
@@ -24,40 +27,32 @@ export type AstroBuildPlugin = {
};
export function createPluginContainer(options: StaticBuildOptions, internals: BuildInternals) {
- const clientPlugins: AstroBuildPlugin[] = [];
- const ssrPlugins: AstroBuildPlugin[] = [];
+ const plugins = new Map<BuildTarget, AstroBuildPlugin[]>();
const allPlugins = new Set<AstroBuildPlugin>();
+ for (const target of ['client', 'server'] satisfies BuildTarget[]) {
+ plugins.set(target, []);
+ }
return {
options,
internals,
register(plugin: AstroBuildPlugin) {
allPlugins.add(plugin);
- switch (plugin.build) {
- case 'client': {
- clientPlugins.push(plugin);
- break;
- }
- case 'ssr': {
- ssrPlugins.push(plugin);
- break;
- }
- case 'both': {
- clientPlugins.push(plugin);
- ssrPlugins.push(plugin);
- break;
- }
+ for (const target of plugin.targets) {
+ const targetPlugins = plugins.get(target) ?? [];
+ targetPlugins.push(plugin);
+ plugins.set(target, targetPlugins);
}
},
// Hooks
- runBeforeHook(build: 'ssr' | 'client', input: Set<string>) {
- let plugins = build === 'ssr' ? ssrPlugins : clientPlugins;
+ async runBeforeHook(target: BuildTarget, input: Set<string>) {
+ let targetPlugins = plugins.get(target) ?? [];
let vitePlugins: Array<VitePlugin | VitePlugin[]> = [];
let lastVitePlugins: Array<VitePlugin | VitePlugin[]> = [];
- for (const plugin of plugins) {
+ for (const plugin of targetPlugins) {
if (plugin.hooks?.['build:before']) {
- let result = plugin.hooks['build:before']({ build, input });
+ let result = await plugin.hooks['build:before']({ target, input });
if (result.vitePlugin) {
vitePlugins.push(result.vitePlugin);
}
@@ -74,7 +69,7 @@ export function createPluginContainer(options: StaticBuildOptions, internals: Bu
const mutations = new Map<
string,
{
- build: 'server' | 'client';
+ targets: BuildTarget[];
code: string;
}
>();
@@ -93,10 +88,10 @@ export function createPluginContainer(options: StaticBuildOptions, internals: Bu
clientOutputs.push(clientReturn);
}
- const mutate: MutateChunk = (chunk, build, newCode) => {
+ const mutate: MutateChunk = (chunk, targets, newCode) => {
chunk.code = newCode;
mutations.set(chunk.fileName, {
- build,
+ targets,
code: newCode,
});
};
diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts
index 90620cb28..3e6a5e6d6 100644
--- a/packages/astro/src/core/build/plugins/index.ts
+++ b/packages/astro/src/core/build/plugins/index.ts
@@ -4,6 +4,7 @@ import type { AstroBuildPluginContainer } from '../plugin.js';
import { pluginAliasResolve } from './plugin-alias-resolve.js';
import { pluginAnalyzer } from './plugin-analyzer.js';
import { pluginComponentEntry } from './plugin-component-entry.js';
+import { pluginContent } from './plugin-content.js';
import { pluginCSS } from './plugin-css.js';
import { pluginHoistedScripts } from './plugin-hoisted-scripts.js';
import { pluginInternals } from './plugin-internals.js';
@@ -12,6 +13,7 @@ import { pluginMiddleware } from './plugin-middleware.js';
import { pluginPages } from './plugin-pages.js';
import { pluginPrerender } from './plugin-prerender.js';
import { pluginRenderers } from './plugin-renderers.js';
+import { pluginChunks } from './plugin-chunks.js';
import { pluginSSR, pluginSSRSplit } from './plugin-ssr.js';
export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) {
@@ -23,6 +25,7 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP
register(pluginRenderers(options));
register(pluginMiddleware(options, internals));
register(pluginPages(options, internals));
+ register(pluginContent(options, internals));
register(pluginCSS(options, internals));
register(astroHeadBuildPlugin(internals));
register(pluginPrerender(options, internals));
@@ -30,4 +33,5 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP
register(pluginHoistedScripts(options, internals));
register(pluginSSR(options, internals));
register(pluginSSRSplit(options, internals));
+ register(pluginChunks());
}
diff --git a/packages/astro/src/core/build/plugins/plugin-alias-resolve.ts b/packages/astro/src/core/build/plugins/plugin-alias-resolve.ts
index 052ea45b7..6fb09acf8 100644
--- a/packages/astro/src/core/build/plugins/plugin-alias-resolve.ts
+++ b/packages/astro/src/core/build/plugins/plugin-alias-resolve.ts
@@ -52,7 +52,7 @@ function matches(pattern: string | RegExp, importee: string) {
export function pluginAliasResolve(internals: BuildInternals): AstroBuildPlugin {
return {
- build: 'client',
+ targets: ['client'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-analyzer.ts b/packages/astro/src/core/build/plugins/plugin-analyzer.ts
index b81932dce..b99624a86 100644
--- a/packages/astro/src/core/build/plugins/plugin-analyzer.ts
+++ b/packages/astro/src/core/build/plugins/plugin-analyzer.ts
@@ -330,7 +330,7 @@ export function pluginAnalyzer(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-chunks.ts b/packages/astro/src/core/build/plugins/plugin-chunks.ts
new file mode 100644
index 000000000..3a2767ef1
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-chunks.ts
@@ -0,0 +1,33 @@
+import type { Plugin as VitePlugin } from 'vite';
+import type { AstroBuildPlugin } from '../plugin.js';
+import { extendManualChunks } from './util.js';
+
+export function vitePluginChunks(): VitePlugin {
+ return {
+ name: 'astro:chunks',
+ outputOptions(outputOptions) {
+ extendManualChunks(outputOptions, {
+ after(id) {
+ // Place Astro's server runtime in a single `astro/server.mjs` file
+ if (id.includes('astro/dist/runtime/server/')) {
+ return 'astro/server'
+ }
+ },
+ });
+ }
+ }
+}
+
+// Build plugin that configures specific chunking behavior
+export function pluginChunks(): AstroBuildPlugin {
+ return {
+ targets: ['server'],
+ hooks: {
+ 'build:before': () => {
+ return {
+ vitePlugin: vitePluginChunks(),
+ };
+ },
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-component-entry.ts b/packages/astro/src/core/build/plugins/plugin-component-entry.ts
index 01e480e2f..bfa2ce58c 100644
--- a/packages/astro/src/core/build/plugins/plugin-component-entry.ts
+++ b/packages/astro/src/core/build/plugins/plugin-component-entry.ts
@@ -77,7 +77,7 @@ export function normalizeEntryId(id: string): string {
export function pluginComponentEntry(internals: BuildInternals): AstroBuildPlugin {
return {
- build: 'client',
+ targets: ['client'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-content.ts b/packages/astro/src/core/build/plugins/plugin-content.ts
new file mode 100644
index 000000000..537fbbc12
--- /dev/null
+++ b/packages/astro/src/core/build/plugins/plugin-content.ts
@@ -0,0 +1,302 @@
+import { normalizePath, type Plugin as VitePlugin } from 'vite';
+import fsMod from 'node:fs';
+import { createHash } from 'node:crypto';
+import { addRollupInput } from '../add-rollup-input.js';
+import { type BuildInternals } from '../internal.js';
+import type { AstroBuildPlugin } from '../plugin.js';
+import type { StaticBuildOptions } from '../types.js';
+import { generateContentEntryFile, generateLookupMap } from '../../../content/vite-plugin-content-virtual-mod.js';
+import { joinPaths, removeFileExtension, removeLeadingForwardSlash } from '../../path.js';
+import { fileURLToPath } from 'node:url';
+import { type ContentLookupMap, hasContentFlag } from '../../../content/utils.js';
+import { CONTENT_RENDER_FLAG, PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
+import { copyFiles } from '../static-build.js';
+import pLimit from 'p-limit';
+import { extendManualChunks } from './util.js';
+import { isServerLikeOutput } from '../../../prerender/utils.js';
+import { encodeName } from '../util.js';
+
+const CONTENT_CACHE_DIR = './content/';
+const CONTENT_MANIFEST_FILE = './manifest.json';
+// IMPORTANT: Update this version when making significant changes to the manifest format.
+// Only manifests generated with the same version number can be compared.
+const CONTENT_MANIFEST_VERSION = 0;
+
+interface ContentManifestKey {
+ collection: string;
+ type: 'content' | 'data';
+ entry: string;
+}
+interface ContentManifest {
+ version: number;
+ entries: [ContentManifestKey, string][];
+ // Tracks components that should be included in the server build
+ // When the cache is restored, these might no longer be referenced
+ serverEntries: string[];
+ // Tracks components that should be passed to the client build
+ // When the cache is restored, these might no longer be referenced
+ clientEntries: string[];
+}
+
+const virtualEmptyModuleId = `virtual:empty-content`;
+const resolvedVirtualEmptyModuleId = `\0${virtualEmptyModuleId}`;
+
+function createContentManifest(): ContentManifest {
+ return { version: -1, entries: [], serverEntries: [], clientEntries: [] };
+}
+
+function vitePluginContent(opts: StaticBuildOptions, lookupMap: ContentLookupMap, internals: BuildInternals): VitePlugin {
+ const { config } = opts.settings;
+ const { cacheDir } = config;
+ const distRoot = config.outDir;
+ const distContentRoot = new URL('./content/', distRoot);
+ const cachedChunks = new URL('./chunks/', opts.settings.config.cacheDir);
+ const distChunks = new URL('./chunks/', opts.settings.config.outDir);
+ const contentCacheDir = new URL(CONTENT_CACHE_DIR, cacheDir);
+ const contentManifestFile = new URL(CONTENT_MANIFEST_FILE, contentCacheDir);
+ const cache = contentCacheDir;
+ const cacheTmp = new URL('./.tmp/', cache);
+ let oldManifest = createContentManifest();
+ let newManifest = createContentManifest();
+ let entries: ContentEntries;
+ let injectedEmptyFile = false;
+
+ if (fsMod.existsSync(contentManifestFile)) {
+ try {
+ const data = fsMod.readFileSync(contentManifestFile, { encoding: 'utf8' });
+ oldManifest = JSON.parse(data);
+ internals.cachedClientEntries = oldManifest.clientEntries;
+ } catch { }
+ }
+
+ return {
+ name: '@astro/plugin-build-content',
+
+ async options(options) {
+ let newOptions = Object.assign({}, options);
+ newManifest = await generateContentManifest(opts, lookupMap);
+ entries = getEntriesFromManifests(oldManifest, newManifest);
+
+ // Of the cached entries, these ones need to be rebuilt
+ for (const { type, entry } of entries.buildFromSource) {
+ const fileURL = encodeURI(joinPaths(opts.settings.config.root.toString(), entry));
+ const input = fileURLToPath(fileURL);
+ // Adds `/src/content/blog/post-1.md?astroContentCollectionEntry` as a top-level input
+ const inputs = [`${input}?${collectionTypeToFlag(type)}`];
+ if (type === 'content') {
+ // Content entries also need to include the version with the RENDER flag
+ inputs.push(`${input}?${CONTENT_RENDER_FLAG}`)
+ }
+ newOptions = addRollupInput(newOptions, inputs);
+ }
+ // Restores cached chunks from the previous build
+ if (fsMod.existsSync(cachedChunks)) {
+ await copyFiles(cachedChunks, distChunks, true);
+ }
+ // If nothing needs to be rebuilt, we inject a fake entrypoint to appease Rollup
+ if (entries.buildFromSource.length === 0) {
+ newOptions = addRollupInput(newOptions, [virtualEmptyModuleId])
+ injectedEmptyFile = true;
+ }
+ return newOptions;
+ },
+
+ outputOptions(outputOptions) {
+ const rootPath = normalizePath(fileURLToPath(opts.settings.config.root));
+ const srcPath = normalizePath(fileURLToPath(opts.settings.config.srcDir));
+ extendManualChunks(outputOptions, {
+ before(id, meta) {
+ if (id.startsWith(srcPath) && id.slice(srcPath.length).startsWith('content')) {
+ const info = meta.getModuleInfo(id);
+ if (info?.dynamicImporters.length === 1 && hasContentFlag(info.dynamicImporters[0], PROPAGATED_ASSET_FLAG)) {
+ const [srcRelativePath] = id.replace(rootPath, '/').split('?');
+ const resultId = encodeName(`${removeLeadingForwardSlash(removeFileExtension(srcRelativePath))}.render.mjs`);
+ return resultId;
+ }
+ const [srcRelativePath, flag] = id.replace(rootPath, '/').split('?');
+ const collectionEntry = findEntryFromSrcRelativePath(lookupMap, srcRelativePath);
+ if (collectionEntry) {
+ let suffix = '.mjs';
+ if (flag === PROPAGATED_ASSET_FLAG) {
+ suffix = '.entry.mjs';
+ }
+ id = removeLeadingForwardSlash(removeFileExtension(encodeName(id.replace(srcPath, '/')))) + suffix;
+ return id;
+ }
+ }
+ }
+ });
+ },
+
+ resolveId(id) {
+ if (id === virtualEmptyModuleId) {
+ return resolvedVirtualEmptyModuleId;
+ }
+ },
+
+ async load(id) {
+ if (id === resolvedVirtualEmptyModuleId) {
+ return {
+ code: `// intentionally left empty!\nexport default {}`
+ }
+ }
+ },
+
+ async generateBundle(_options, bundle) {
+ const code = await generateContentEntryFile({ settings: opts.settings, fs: fsMod, lookupMap, IS_DEV: false, IS_SERVER: false });
+ this.emitFile({
+ type: 'prebuilt-chunk',
+ code,
+ fileName: 'content/entry.mjs'
+ })
+ if (!injectedEmptyFile) return;
+ Object.keys(bundle).forEach(key => {
+ const mod = bundle[key];
+ if (mod.type === 'asset') return;
+ if (mod.facadeModuleId === resolvedVirtualEmptyModuleId) {
+ delete bundle[key];
+ }
+ });
+ },
+
+ async writeBundle() {
+ // These are stored in the manifest to ensure that they are included in the build
+ // in case they aren't referenced _outside_ of the cached content.
+ // We can use this info in the manifest to run a proper client build again.
+ const clientComponents = new Set([
+ ...oldManifest.clientEntries,
+ ...internals.discoveredHydratedComponents.keys(),
+ ...internals.discoveredClientOnlyComponents.keys(),
+ ...internals.discoveredScripts,
+ ])
+ // Likewise, these are server modules that might not be referenced
+ // once the cached items are excluded from the build process
+ const serverComponents = new Set([
+ ...oldManifest.serverEntries,
+ ...internals.discoveredHydratedComponents.keys(),
+ ]);
+ newManifest.serverEntries = Array.from(serverComponents);
+ newManifest.clientEntries = Array.from(clientComponents);
+ await fsMod.promises.mkdir(contentCacheDir, { recursive: true });
+ await fsMod.promises.writeFile(contentManifestFile, JSON.stringify(newManifest), { encoding: 'utf8' });
+
+ const cacheExists = fsMod.existsSync(cache);
+ fsMod.mkdirSync(cache, { recursive: true })
+ await fsMod.promises.mkdir(cacheTmp, { recursive: true });
+ await copyFiles(distContentRoot, cacheTmp, true);
+ if (cacheExists) {
+ await copyFiles(contentCacheDir, distContentRoot, false);
+ }
+ await copyFiles(cacheTmp, contentCacheDir);
+ await fsMod.promises.rm(cacheTmp, { recursive: true, force: true });
+ }
+ };
+}
+
+const entryCache = new Map<string, string>();
+function findEntryFromSrcRelativePath(lookupMap: ContentLookupMap, srcRelativePath: string) {
+ let value = entryCache.get(srcRelativePath);
+ if (value) return value;
+ for (const collection of Object.values(lookupMap)) {
+ for (const entry of Object.values(collection)) {
+ for (const entryFile of Object.values(entry)) {
+ if (entryFile === srcRelativePath) {
+ value = entryFile;
+ entryCache.set(srcRelativePath, entryFile);
+ return value;
+ }
+ }
+ }
+ }
+}
+
+interface ContentEntries {
+ restoreFromCache: ContentManifestKey[];
+ buildFromSource: ContentManifestKey[];
+}
+function getEntriesFromManifests(oldManifest: ContentManifest, newManifest: ContentManifest): ContentEntries {
+ const { version: oldVersion, entries: oldEntries } = oldManifest;
+ const { version: newVersion, entries: newEntries } = newManifest;
+ let entries: ContentEntries = { restoreFromCache: [], buildFromSource: [] };
+
+ const newEntryMap = new Map<ContentManifestKey, string>(newEntries);
+ if (oldVersion !== newVersion || oldEntries.length === 0) {
+ entries.buildFromSource = Array.from(newEntryMap.keys());
+ return entries;
+ }
+ const oldEntryHashMap = new Map<string, ContentManifestKey>(oldEntries.map(([key, hash]) => [hash, key]))
+
+ for (const [entry, hash] of newEntryMap) {
+ if (oldEntryHashMap.has(hash)) {
+ entries.restoreFromCache.push(entry);
+ } else {
+ entries.buildFromSource.push(entry);
+ }
+ }
+ return entries;
+}
+
+async function generateContentManifest(opts: StaticBuildOptions, lookupMap: ContentLookupMap): Promise<ContentManifest> {
+ let manifest: ContentManifest = { version: CONTENT_MANIFEST_VERSION, entries: [], serverEntries: [], clientEntries: [] };
+ const limit = pLimit(10);
+ const promises: Promise<void>[] = [];
+
+ for (const [collection, { type, entries }] of Object.entries(lookupMap)) {
+ for (const entry of Object.values(entries)) {
+ const key: ContentManifestKey = { collection, type, entry };
+ const fileURL = new URL(encodeURI(joinPaths(opts.settings.config.root.toString(), entry)));
+ promises.push(limit(async () => {
+ const data = await fsMod.promises.readFile(fileURL, { encoding: 'utf8' });
+ manifest.entries.push([key, checksum(data)])
+ }));
+ }
+ }
+
+ await Promise.all(promises);
+ return manifest;
+}
+
+function checksum(data: string): string {
+ return createHash('sha1').update(data).digest('base64');
+}
+
+function collectionTypeToFlag(type: 'content' | 'data') {
+ const name = type[0].toUpperCase() + type.slice(1);
+ return `astro${name}CollectionEntry`
+}
+
+export function pluginContent(opts: StaticBuildOptions, internals: BuildInternals): AstroBuildPlugin {
+ const cachedChunks = new URL('./chunks/', opts.settings.config.cacheDir);
+ const distChunks = new URL('./chunks/', opts.settings.config.outDir);
+
+ return {
+ targets: ['server'],
+ hooks: {
+ async 'build:before'() {
+ if (!opts.settings.config.experimental.contentCollectionCache) {
+ return { vitePlugin: undefined };
+ }
+ if (isServerLikeOutput(opts.settings.config)) {
+ return { vitePlugin: undefined };
+ }
+
+ const lookupMap = await generateLookupMap({ settings: opts.settings, fs: fsMod });
+ return {
+ vitePlugin: vitePluginContent(opts, lookupMap, internals),
+ };
+ },
+
+ async 'build:post'() {
+ if (!opts.settings.config.experimental.contentCollectionCache) {
+ return;
+ }
+ if (isServerLikeOutput(opts.settings.config)) {
+ return;
+ }
+ if (fsMod.existsSync(distChunks)) {
+ await copyFiles(distChunks, cachedChunks, true);
+ }
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts
index 5e24f85d2..dd5d0af03 100644
--- a/packages/astro/src/core/build/plugins/plugin-css.ts
+++ b/packages/astro/src/core/build/plugins/plugin-css.ts
@@ -2,7 +2,7 @@ import type { GetModuleInfo } from 'rollup';
import { type ResolvedConfig, type Plugin as VitePlugin } from 'vite';
import { isBuildableCSSRequest } from '../../../vite-plugin-astro-server/util.js';
import type { BuildInternals } from '../internal.js';
-import type { AstroBuildPlugin } from '../plugin.js';
+import type { AstroBuildPlugin, BuildTarget } from '../plugin.js';
import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types.js';
import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
@@ -20,7 +20,7 @@ import { extendManualChunks } from './util.js';
interface PluginOptions {
internals: BuildInternals;
buildOptions: StaticBuildOptions;
- target: 'client' | 'server';
+ target: BuildTarget;
}
/***** ASTRO PLUGIN *****/
@@ -30,13 +30,13 @@ export function pluginCSS(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'both',
+ targets: ['client', 'server'],
hooks: {
- 'build:before': ({ build }) => {
+ 'build:before': ({ target }) => {
let plugins = rollupPluginAstroBuildCSS({
buildOptions: options,
internals,
- target: build === 'ssr' ? 'server' : 'client',
+ target,
});
return {
@@ -93,6 +93,12 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
// so they can be injected where needed
const chunkId = assetName.createNameHash(id, [id]);
internals.cssModuleToChunkIdMap.set(id, chunkId);
+ if (options.buildOptions.settings.config.output === 'static' && options.buildOptions.settings.config.experimental.contentCollectionCache) {
+ // TODO: Handle inlining?
+ const propagatedStyles = internals.propagatedStylesMap.get(pageInfo.id) ?? new Set();
+ propagatedStyles.add({ type: 'external', src: chunkId });
+ internals.propagatedStylesMap.set(pageInfo.id, propagatedStyles);
+ }
return chunkId;
}
}
@@ -242,8 +248,8 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
if (pageData.styles.some((s) => s.sheet === sheet)) return;
const propagatedStyles =
- pageData.propagatedStyles.get(pageInfoId) ??
- pageData.propagatedStyles.set(pageInfoId, new Set()).get(pageInfoId)!;
+ internals.propagatedStylesMap.get(pageInfoId) ??
+ internals.propagatedStylesMap.set(pageInfoId, new Set()).get(pageInfoId)!;
propagatedStyles.add(sheet);
sheetAddedToPage = true;
diff --git a/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts b/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts
index 5c6b40992..1c02f7adc 100644
--- a/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts
+++ b/packages/astro/src/core/build/plugins/plugin-hoisted-scripts.ts
@@ -108,7 +108,7 @@ export function pluginHoistedScripts(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'client',
+ targets: ['client'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts
index ab79e5f47..4f18e0245 100644
--- a/packages/astro/src/core/build/plugins/plugin-internals.ts
+++ b/packages/astro/src/core/build/plugins/plugin-internals.ts
@@ -61,7 +61,7 @@ export function vitePluginInternals(input: Set<string>, internals: BuildInternal
export function pluginInternals(internals: BuildInternals): AstroBuildPlugin {
return {
- build: 'both',
+ targets: ['client', 'server'],
hooks: {
'build:before': ({ input }) => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
index 2e912eab5..337719163 100644
--- a/packages/astro/src/core/build/plugins/plugin-manifest.ts
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -83,7 +83,7 @@ export function pluginManifest(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
@@ -111,7 +111,7 @@ export function pluginManifest(
: undefined,
});
const code = injectManifest(manifest, internals.manifestEntryChunk);
- mutate(internals.manifestEntryChunk, 'server', code);
+ mutate(internals.manifestEntryChunk, ['server'], code);
},
},
};
diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts
index bfc72e1a0..701422a5b 100644
--- a/packages/astro/src/core/build/plugins/plugin-middleware.ts
+++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts
@@ -9,7 +9,7 @@ export function pluginMiddleware(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts
index e72e38440..2a348f18f 100644
--- a/packages/astro/src/core/build/plugins/plugin-pages.ts
+++ b/packages/astro/src/core/build/plugins/plugin-pages.ts
@@ -104,7 +104,7 @@ export function shouldBundleMiddleware(settings: AstroSettings) {
export function pluginPages(opts: StaticBuildOptions, internals: BuildInternals): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-prerender.ts b/packages/astro/src/core/build/plugins/plugin-prerender.ts
index 35e4813fc..d3d5305e4 100644
--- a/packages/astro/src/core/build/plugins/plugin-prerender.ts
+++ b/packages/astro/src/core/build/plugins/plugin-prerender.ts
@@ -12,16 +12,12 @@ function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals
outputOptions(outputOptions) {
extendManualChunks(outputOptions, {
- after(id, meta) {
- // Split the Astro runtime into a separate chunk for readability
- if (id.includes('astro/dist/runtime')) {
- return 'astro';
- }
+ before(id, meta) {
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 (getPrerenderMetadata(meta.getModuleInfo(id))) {
+ if (getPrerenderMetadata(meta.getModuleInfo(id)!)) {
pageInfo.route.prerender = true;
return 'prerender';
}
@@ -40,7 +36,7 @@ export function pluginPrerender(
internals: BuildInternals
): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-renderers.ts b/packages/astro/src/core/build/plugins/plugin-renderers.ts
index 6cb45dc59..c5853dca5 100644
--- a/packages/astro/src/core/build/plugins/plugin-renderers.ts
+++ b/packages/astro/src/core/build/plugins/plugin-renderers.ts
@@ -48,7 +48,7 @@ export function vitePluginRenderers(opts: StaticBuildOptions): VitePlugin {
export function pluginRenderers(opts: StaticBuildOptions): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
return {
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
index 14152f6fb..fd892c9b6 100644
--- a/packages/astro/src/core/build/plugins/plugin-ssr.ts
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -98,7 +98,7 @@ export function pluginSSR(
const ssr = isServerLikeOutput(options.settings.config);
const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter);
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
let vitePlugin =
@@ -219,7 +219,7 @@ export function pluginSSRSplit(
const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter);
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before': () => {
let vitePlugin =
diff --git a/packages/astro/src/core/build/plugins/util.ts b/packages/astro/src/core/build/plugins/util.ts
index fa79d72bd..4ab36fb23 100644
--- a/packages/astro/src/core/build/plugins/util.ts
+++ b/packages/astro/src/core/build/plugins/util.ts
@@ -1,13 +1,13 @@
import { extname } from 'node:path';
-import type { Plugin as VitePlugin } from 'vite';
+import type { Rollup, Plugin as VitePlugin } from 'vite';
// eslint-disable-next-line @typescript-eslint/ban-types
type OutputOptionsHook = Extract<VitePlugin['outputOptions'], Function>;
type OutputOptions = Parameters<OutputOptionsHook>[0];
type ExtendManualChunksHooks = {
- before?: (id: string, meta: any) => string | undefined;
- after?: (id: string, meta: any) => string | undefined;
+ before?: Rollup.GetManualChunk;
+ after?: Rollup.GetManualChunk;
};
export function extendManualChunks(outputOptions: OutputOptions, hooks: ExtendManualChunksHooks) {
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 43727b876..e80c288c0 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -13,7 +13,7 @@ import {
type BuildInternals,
} from '../../core/build/internal.js';
import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js';
-import { appendForwardSlash, prependForwardSlash } from '../../core/path.js';
+import { appendForwardSlash, prependForwardSlash, removeFileExtension } from '../../core/path.js';
import { isModeServerWithNoAdapter } from '../../core/util.js';
import { runHookBuildSetup } from '../../integrations/index.js';
import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js';
@@ -31,7 +31,9 @@ import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
import type { StaticBuildOptions } from './types.js';
-import { getTimeStat } from './util.js';
+import { encodeName, getTimeStat } from './util.js';
+import { hasAnyContentFlag } from '../../content/utils.js';
+import { PROPAGATED_ASSET_FLAG } from '../../content/consts.js';
export async function viteBuild(opts: StaticBuildOptions) {
const { allPages, settings } = opts;
@@ -79,8 +81,8 @@ export async function viteBuild(opts: StaticBuildOptions) {
opts.logger.info('build', `Building ${settings.config.output} entrypoints...`);
const ssrOutput = await ssrBuild(opts, internals, pageInput, container);
opts.logger.info('build', dim(`Completed in ${getTimeStat(ssrTime, performance.now())}.`));
-
settings.timer.end('SSR build');
+
settings.timer.start('Client build');
const rendererClientEntrypoints = settings.renderers
@@ -88,6 +90,7 @@ export async function viteBuild(opts: StaticBuildOptions) {
.filter((a) => typeof a === 'string') as string[];
const clientInput = new Set([
+ ...internals.cachedClientEntries,
...internals.discoveredHydratedComponents.keys(),
...internals.discoveredClientOnlyComponents.keys(),
...rendererClientEntrypoints,
@@ -142,13 +145,15 @@ async function ssrBuild(
input: Set<string>,
container: AstroBuildPluginContainer
) {
+ const buildID = Date.now().toString();
const { allPages, settings, viteConfig } = opts;
const ssr = isServerLikeOutput(settings.config);
const out = getOutputDirectory(settings.config);
const routes = Object.values(allPages)
.flat()
.map((pageData) => pageData.route);
- const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input);
+ const isContentCache = !ssr && settings.config.experimental.contentCollectionCache;
+ const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);
const viteBuildConfig: vite.InlineConfig = {
...viteConfig,
@@ -169,34 +174,40 @@ async function ssrBuild(
...viteConfig.build?.rollupOptions,
input: [],
output: {
+ hoistTransitiveImports: isContentCache,
format: 'esm',
+ minifyInternalExports: !isContentCache,
// Server chunks can't go in the assets (_astro) folder
// We need to keep these separate
chunkFileNames(chunkInfo) {
const { name } = chunkInfo;
+ let prefix = 'chunks/';
+ let suffix = '_[hash].mjs';
+
+ if (isContentCache) {
+ prefix += `${buildID}/`;
+ suffix = '.mjs';
+ }
+
+ if (isContentCache && name.includes('/content/')) {
+ const parts = name.split('/');
+ if (parts.at(1) === 'content') {
+ return encodeName(parts.slice(1).join('/'));
+ }
+ }
// Sometimes chunks have the `@_@astro` suffix due to SSR logic. Remove it!
// TODO: refactor our build logic to avoid this
if (name.includes(ASTRO_PAGE_EXTENSION_POST_PATTERN)) {
const [sanitizedName] = name.split(ASTRO_PAGE_EXTENSION_POST_PATTERN);
- return `chunks/${sanitizedName}_[hash].mjs`;
+ return [prefix, sanitizedName, suffix].join('');
}
// Injected routes include "pages/[name].[ext]" already. Clean those up!
if (name.startsWith('pages/')) {
const sanitizedName = name.split('.')[0];
- return `chunks/${sanitizedName}_[hash].mjs`;
+ return [prefix, sanitizedName, suffix].join('');
}
- // 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
- // We do this because you cannot import a module with this character in it.
- for (let i = 0; i < name.length; i++) {
- if (name[i] === '%') {
- const third = name.codePointAt(i + 2)! | 0x20;
- if (name[i + 1] !== '2' || third !== 102) {
- return `chunks/${name.replace(/%/g, '_percent_')}_[hash].mjs`;
- }
- }
- }
- return `chunks/[name]_[hash].mjs`;
+ const encoded = encodeName(name);
+ return [prefix, encoded, suffix].join('')
},
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
...viteConfig.build?.rollupOptions?.output,
@@ -215,6 +226,12 @@ async function ssrBuild(
return 'renderers.mjs';
} else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
return 'manifest_[hash].mjs';
+ } else if (settings.config.experimental.contentCollectionCache && chunkInfo.facadeModuleId && hasAnyContentFlag(chunkInfo.facadeModuleId)) {
+ const [srcRelative, flag] = chunkInfo.facadeModuleId.split('/src/')[1].split('?');
+ if (flag === PROPAGATED_ASSET_FLAG) {
+ return encodeName(`${removeFileExtension(srcRelative)}.entry.mjs`);
+ }
+ return encodeName(`${removeFileExtension(srcRelative)}.mjs`);
} else {
return '[name].mjs';
}
@@ -265,7 +282,7 @@ async function clientBuild(
return null;
}
- const { lastVitePlugins, vitePlugins } = container.runBeforeHook('client', input);
+ const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('client', input);
opts.logger.info(null, `\n${bgGreen(black(' building client '))}`);
const viteBuildConfig: vite.InlineConfig = {
@@ -319,7 +336,7 @@ async function runPostBuildHooks(
const build = container.options.settings.config.build;
for (const [fileName, mutation] of mutations) {
const root = isServerLikeOutput(config)
- ? mutation.build === 'server'
+ ? mutation.targets.includes('server')
? build.server
: build.client
: config.outDir;
@@ -410,20 +427,23 @@ async function cleanServerOutput(opts: StaticBuildOptions) {
}
}
-async function copyFiles(fromFolder: URL, toFolder: URL, includeDotfiles = false) {
+export async function copyFiles(fromFolder: URL, toFolder: URL, includeDotfiles = false) {
const files = await glob('**/*', {
cwd: fileURLToPath(fromFolder),
dot: includeDotfiles,
});
-
+ if (files.length === 0) return;
await Promise.all(
- files.map(async (filename) => {
+ files.map(async function copyFile(filename) {
const from = new URL(filename, fromFolder);
const to = new URL(filename, toFolder);
const lastFolder = new URL('./', to);
return fs.promises
.mkdir(lastFolder, { recursive: true })
- .then(() => fs.promises.copyFile(from, to));
+ .then(async function fsCopyFile() {
+ const p = await fs.promises.copyFile(from, to, fs.constants.COPYFILE_FICLONE);
+ return p;
+ });
})
);
}
@@ -444,7 +464,7 @@ async function ssrMoveAssets(opts: StaticBuildOptions) {
if (files.length > 0) {
await Promise.all(
- files.map(async (filename) => {
+ files.map(async function moveAsset(filename) {
const currentUrl = new URL(filename, appendForwardSlash(serverAssets.toString()));
const clientUrl = new URL(filename, appendForwardSlash(clientAssets.toString()));
const dir = new URL(path.parse(clientUrl.href).dir);
@@ -499,7 +519,7 @@ export function makeAstroPageEntryPointFileName(
* 2. We split the file path using the file system separator and attempt to retrieve the last entry
* 3. The last entry should be the file
* 4. We prepend the file name with `entry.`
- * 5. We built the file path again, using the new entry built in the previous step
+ * 5. We built the file path again, using the new en3built in the previous step
*
* @param facadeModuleId
* @param opts
diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts
index f289f019b..e3551f73a 100644
--- a/packages/astro/src/core/build/util.ts
+++ b/packages/astro/src/core/build/util.ts
@@ -36,3 +36,19 @@ export function i18nHasFallback(config: AstroConfig): boolean {
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
+ // We do this because you cannot import a module with this character in it.
+ for(let i = 0; i < name.length; i++) {
+ if(name[i] === '%') {
+ const third = name.codePointAt(i + 2)! | 0x20;
+ if (name[i + 1] !== '2' || third !== 102) {
+ return `${name.replace(/%/g, '_percent_')}`;
+ }
+ }
+ }
+
+ return name;
+}
diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts
index d8fdd9bb0..53acb6924 100644
--- a/packages/astro/src/core/config/config.ts
+++ b/packages/astro/src/core/config/config.ts
@@ -45,6 +45,11 @@ export async function validateConfig(
throw e;
}
+ // TODO: fix inlineStylesheets behavior with content collection cache
+ if (result.build.inlineStylesheets !== 'auto' && result.experimental.contentCollectionCache) {
+ result.experimental.contentCollectionCache = false;
+ }
+
// If successful, return the result as a verified AstroConfig object.
return result;
}
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index e10aa4b75..13b31c7e0 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -61,6 +61,7 @@ const ASTRO_CONFIG_DEFAULTS = {
experimental: {
optimizeHoistedScript: false,
devOverlay: false,
+ contentCollectionCache: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -388,6 +389,7 @@ export const AstroConfigSchema = z.object({
}
})
),
+ contentCollectionCache: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollectionCache),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index fda837209..de6443729 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(
astroHeadPlugin(),
astroScannerPlugin({ settings, logger }),
astroInjectEnvTsPlugin({ settings, logger, fs }),
- astroContentVirtualModPlugin({ settings }),
+ astroContentVirtualModPlugin({ fs, settings }),
astroContentImportPlugin({ fs, settings }),
astroContentAssetPropagationPlugin({ mode, settings }),
vitePluginMiddleware({ settings }),
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
index a82b50ff8..68c61bcc6 100644
--- a/packages/astro/src/runtime/server/render/component.ts
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -14,11 +14,16 @@ import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js';
import { isPromise } from '../util.js';
import {
- createAstroComponentInstance,
isAstroComponentFactory,
- renderTemplate,
type AstroComponentFactory,
+} from './astro/factory.js';
+import {
+ createAstroComponentInstance
+} from './astro/instance.js'
+import {
+ renderTemplate,
} from './astro/index.js';
+
import {
Fragment,
Renderer,
diff --git a/packages/astro/src/vite-plugin-head/index.ts b/packages/astro/src/vite-plugin-head/index.ts
index 58722fdb2..228e4e437 100644
--- a/packages/astro/src/vite-plugin-head/index.ts
+++ b/packages/astro/src/vite-plugin-head/index.ts
@@ -103,7 +103,7 @@ export default function configHeadVitePlugin(): vite.Plugin {
export function astroHeadBuildPlugin(internals: BuildInternals): AstroBuildPlugin {
return {
- build: 'ssr',
+ targets: ['server'],
hooks: {
'build:before'() {
return {
diff --git a/packages/astro/test/experimental-content-collection-references.test.js b/packages/astro/test/experimental-content-collection-references.test.js
new file mode 100644
index 000000000..ae55fc9de
--- /dev/null
+++ b/packages/astro/test/experimental-content-collection-references.test.js
@@ -0,0 +1,160 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import { fixLineEndings, loadFixture } from './test-utils.js';
+
+describe('Experimental Content Collections cache - references', () => {
+ let fixture;
+ let devServer;
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-collection-references/', experimental: { contentCollectionCache: true } });
+ });
+
+ after(() => fixture.clean());
+
+ const modes = ['dev', 'prod'];
+
+ for (const mode of modes) {
+ describe(mode, () => {
+ before(async () => {
+ if (mode === 'prod') {
+ await fixture.build();
+ } else if (mode === 'dev') {
+ devServer = await fixture.startDevServer();
+ }
+ });
+
+ after(async () => {
+ if (mode === 'dev') devServer?.stop();
+ });
+
+ describe(`JSON result`, () => {
+ let json;
+ before(async () => {
+ if (mode === 'prod') {
+ const rawJson = await fixture.readFile('/welcome-data.json');
+ json = JSON.parse(rawJson);
+ } else if (mode === 'dev') {
+ const rawJsonResponse = await fixture.fetch('/welcome-data.json');
+ const rawJson = await rawJsonResponse.text();
+ json = JSON.parse(rawJson);
+ }
+ });
+
+ it('Returns expected keys', () => {
+ expect(json).to.haveOwnProperty('welcomePost');
+ expect(json).to.haveOwnProperty('banner');
+ expect(json).to.haveOwnProperty('author');
+ expect(json).to.haveOwnProperty('relatedPosts');
+ });
+
+ it('Returns `banner` data', () => {
+ const { banner } = json;
+ expect(banner).to.haveOwnProperty('data');
+ expect(banner.id).to.equal('welcome');
+ expect(banner.collection).to.equal('banners');
+ expect(banner.data.alt).to.equal(
+ 'Futuristic landscape with chrome buildings and blue skies'
+ );
+
+ expect(banner.data.src.width).to.equal(400);
+ expect(banner.data.src.height).to.equal(225);
+ expect(banner.data.src.format).to.equal('jpg');
+ expect(banner.data.src.src.includes('the-future')).to.be.true;
+ });
+
+ it('Returns `author` data', () => {
+ const { author } = json;
+ expect(author).to.haveOwnProperty('data');
+ expect(author).to.deep.equal({
+ id: 'nate-moore',
+ collection: 'authors',
+ data: {
+ name: 'Nate Something Moore',
+ twitter: 'https://twitter.com/n_moore',
+ },
+ });
+ });
+
+ it('Returns `relatedPosts` data', () => {
+ const { relatedPosts } = json;
+ expect(Array.isArray(relatedPosts)).to.be.true;
+ const topLevelInfo = relatedPosts.map(({ data, body, ...meta }) => ({
+ ...meta,
+ body: fixLineEndings(body).trim(),
+ }));
+ expect(topLevelInfo).to.deep.equal([
+ {
+ id: 'related-1.md',
+ slug: 'related-1',
+ body: '# Related post 1\n\nThis is related to the welcome post.',
+ collection: 'blog',
+ },
+ {
+ id: 'related-2.md',
+ slug: 'related-2',
+ body: '# Related post 2\n\nThis is related to the welcome post.',
+ collection: 'blog',
+ },
+ ]);
+ const postData = relatedPosts.map(({ data }) => data);
+ expect(postData).to.deep.equal([
+ {
+ title: 'Related post 1',
+ banner: { id: 'welcome', collection: 'banners' },
+ author: { id: 'fred-schott', collection: 'authors' },
+ },
+ {
+ title: 'Related post 2',
+ banner: { id: 'welcome', collection: 'banners' },
+ author: { id: 'ben-holmes', collection: 'authors' },
+ },
+ ]);
+ });
+ });
+
+ describe(`Render result`, () => {
+ let $;
+ before(async () => {
+ if (mode === 'prod') {
+ const html = await fixture.readFile('/welcome/index.html');
+ $ = cheerio.load(html);
+ } else if (mode === 'dev') {
+ const htmlResponse = await fixture.fetch('/welcome');
+ const html = await htmlResponse.text();
+ $ = cheerio.load(html);
+ }
+ });
+
+ it('Renders `banner` data', () => {
+ const banner = $('img[data-banner]');
+ expect(banner.length).to.equal(1);
+ expect(banner.attr('src')).to.include('the-future');
+ expect(banner.attr('alt')).to.equal(
+ 'Futuristic landscape with chrome buildings and blue skies'
+ );
+ expect(banner.attr('width')).to.equal('400');
+ expect(banner.attr('height')).to.equal('225');
+ });
+
+ it('Renders `author` data', () => {
+ const author = $('a[data-author-name]');
+ expect(author.length).to.equal(1);
+ expect(author.attr('href')).to.equal('https://twitter.com/n_moore');
+ expect(author.text()).to.equal('Nate Something Moore');
+ });
+
+ it('Renders `relatedPosts` data', () => {
+ const relatedPosts = $('ul[data-related-posts]');
+ expect(relatedPosts.length).to.equal(1);
+ const relatedPost1 = relatedPosts.find('li').eq(0);
+
+ expect(relatedPost1.find('a').attr('href')).to.equal('/blog/related-1');
+ expect(relatedPost1.find('a').text()).to.equal('Related post 1');
+ const relatedPost2 = relatedPosts.find('li').eq(1);
+ expect(relatedPost2.find('a').attr('href')).to.equal('/blog/related-2');
+ expect(relatedPost2.find('a').text()).to.equal('Related post 2');
+ });
+ });
+ });
+ }
+});
diff --git a/packages/astro/test/experimental-content-collections-css-inline-stylesheets.test.js b/packages/astro/test/experimental-content-collections-css-inline-stylesheets.test.js
new file mode 100644
index 000000000..10dee6d9f
--- /dev/null
+++ b/packages/astro/test/experimental-content-collections-css-inline-stylesheets.test.js
@@ -0,0 +1,342 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import { loadFixture } from './test-utils.js';
+import testAdapter from './test-adapter.js';
+
+describe('Experimental Content Collections cache inlineStylesheets', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.dev/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'static',
+ build: {
+ inlineStylesheets: 'never',
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Does not render any <style> tags', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ expect($('style').toArray()).to.be.empty;
+ });
+
+ describe('Inspect linked stylesheets', () => {
+ // object, so it can be passed by reference
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromStaticOutput(fixture);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+describe('Experimental Content Collections cache - inlineStylesheets to never in server output', () => {
+ let app;
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.dev/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'server',
+ adapter: testAdapter(),
+ build: {
+ inlineStylesheets: 'never',
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ after(() => fixture.clean());
+
+ it('Does not render any <style> tags', async () => {
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ expect($('style').toArray()).to.be.empty;
+ });
+
+ describe('Inspect linked stylesheets', () => {
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromServer(app);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+describe.skip('Experimental Content Collections cache - inlineStylesheets to auto in static output', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.info/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'static',
+ build: {
+ inlineStylesheets: 'auto',
+ },
+ vite: {
+ build: {
+ assetsInlineLimit: 512,
+ },
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it.skip('Renders some <style> and some <link> tags', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ // the count of style/link tags depends on our css chunking logic
+ // this test should be updated if it changes
+ expect($('style')).to.have.lengthOf(3);
+ expect($('link[rel=stylesheet]')).to.have.lengthOf(1);
+ });
+
+ describe('Inspect linked and inlined stylesheets', () => {
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromStaticOutput(fixture);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+describe('Setting inlineStylesheets to auto in server output', () => {
+ let app;
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.info/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'server',
+ adapter: testAdapter(),
+ build: {
+ inlineStylesheets: 'auto',
+ },
+ vite: {
+ build: {
+ assetsInlineLimit: 512,
+ },
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ after(() => fixture.clean());
+
+ it('Renders some <style> and some <link> tags', async () => {
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // the count of style/link tags depends on our css chunking logic
+ // this test should be updated if it changes
+ expect($('style')).to.have.lengthOf(3);
+ expect($('link[rel=stylesheet]')).to.have.lengthOf(1);
+ });
+
+ describe('Inspect linked and inlined stylesheets', () => {
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromServer(app);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+describe('Setting inlineStylesheets to always in static output', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.net/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'static',
+ build: {
+ inlineStylesheets: 'always',
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Does not render any <link> tags', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ expect($('link[rel=stylesheet]').toArray()).to.be.empty;
+ });
+
+ describe('Inspect inlined stylesheets', () => {
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromStaticOutput(fixture);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+describe('Setting inlineStylesheets to always in server output', () => {
+ let app;
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.net/',
+ root: './fixtures/css-inline-stylesheets/',
+ output: 'server',
+ adapter: testAdapter(),
+ build: {
+ inlineStylesheets: 'always',
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ after(() => fixture.clean());
+
+ it('Does not render any <link> tags', async () => {
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ expect($('link[rel=stylesheet]').toArray()).to.be.empty;
+ });
+
+ describe('Inspect inlined stylesheets', () => {
+ const allStyles = {};
+
+ before(async () => {
+ allStyles.value = await stylesFromServer(app);
+ });
+
+ commonExpectations(allStyles);
+ });
+});
+
+async function stylesFromStaticOutput(fixture) {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ const links = $('link[rel=stylesheet]');
+ const hrefs = links.map((_, linkEl) => linkEl.attribs.href).toArray();
+ const allLinkedStylesheets = await Promise.all(hrefs.map((href) => fixture.readFile(href)));
+ const allLinkedStyles = allLinkedStylesheets.join('');
+
+ const styles = $('style');
+ const allInlinedStylesheets = styles.map((_, styleEl) => styleEl.children[0].data).toArray();
+ const allInlinedStyles = allInlinedStylesheets.join('');
+
+ return allLinkedStyles + allInlinedStyles;
+}
+
+async function stylesFromServer(app) {
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const links = $('link[rel=stylesheet]');
+ const hrefs = links.map((_, linkEl) => linkEl.attribs.href).toArray();
+ const allLinkedStylesheets = await Promise.all(
+ hrefs.map(async (href) => {
+ const cssRequest = new Request(`http://example.com${href}`);
+ const cssResponse = await app.render(cssRequest);
+ return await cssResponse.text();
+ })
+ );
+ const allLinkedStyles = allLinkedStylesheets.join('');
+
+ const styles = $('style');
+ const allInlinedStylesheets = styles.map((_, styleEl) => styleEl.children[0].data).toArray();
+ const allInlinedStyles = allInlinedStylesheets.join('');
+ return allLinkedStyles + allInlinedStyles;
+}
+
+function commonExpectations(allStyles) {
+ it('Includes all authored css', () => {
+ // authored in imported.css
+ expect(allStyles.value).to.include('.bg-lightcoral');
+
+ // authored in index.astro
+ expect(allStyles.value).to.include('#welcome');
+
+ // authored in components/Button.astro
+ expect(allStyles.value).to.include('.variant-outline');
+
+ // authored in layouts/Layout.astro
+ expect(allStyles.value).to.include('Menlo');
+ });
+
+ it('Styles used both in content layout and directly in page are included only once', () => {
+ // authored in components/Button.astro
+ expect(allStyles.value.match(/cubic-bezier/g)).to.have.lengthOf(1);
+ });
+}
diff --git a/packages/astro/test/experimental-content-collections-render.test.js b/packages/astro/test/experimental-content-collections-render.test.js
new file mode 100644
index 000000000..2ae03d6c4
--- /dev/null
+++ b/packages/astro/test/experimental-content-collections-render.test.js
@@ -0,0 +1,288 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+import { loadFixture, isWindows } from './test-utils.js';
+import testAdapter from './test-adapter.js';
+
+const describe = isWindows ? global.describe.skip : global.describe;
+
+describe('Experimental Content Collections cache - render()', () => {
+ describe('Build - SSG', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content/',
+ // test suite was authored when inlineStylesheets defaulted to never
+ build: { inlineStylesheets: 'never' },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Includes CSS for rendered entry', async () => {
+ const html = await fixture.readFile('/launch-week/index.html');
+ const $ = cheerio.load(html);
+
+ // Renders content
+ expect($('ul li')).to.have.a.lengthOf(3);
+
+ // Includes styles
+ expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
+ });
+
+ it('Excludes CSS for non-rendered entries', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ // Excludes styles
+ expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
+ });
+
+ it('De-duplicates CSS used both in layout and directly in target page', async () => {
+ const html = await fixture.readFile('/with-layout-prop/index.html');
+ const $ = cheerio.load(html);
+
+ const set = new Set();
+
+ $('link[rel=stylesheet]').each((_, linkEl) => {
+ const href = linkEl.attribs.href;
+ expect(set).to.not.contain(href);
+ set.add(href);
+ });
+
+ $('style').each((_, styleEl) => {
+ const textContent = styleEl.children[0].data;
+ expect(set).to.not.contain(textContent);
+ set.add(textContent);
+ });
+ });
+
+ it.skip('Includes component scripts for rendered entry', async () => {
+ const html = await fixture.readFile('/launch-week-component-scripts/index.html');
+ const $ = cheerio.load(html);
+
+ const allScripts = $('head > script[type="module"]');
+ expect(allScripts).to.have.length;
+
+ // Includes hoisted script
+ expect(
+ [...allScripts].find((script) => $(script).attr('src')?.includes('WithScripts')),
+ '`WithScripts.astro` hoisted script missing from head.'
+ ).to.not.be.undefined;
+
+ // Includes inline script
+ expect($('script[data-is-inline]')).to.have.a.lengthOf(1);
+ });
+
+ it('Excludes component scripts for non-rendered entries', async () => {
+ const html = await fixture.readFile('/index.html');
+ const $ = cheerio.load(html);
+
+ const allScripts = $('head > script[type="module"]');
+
+ // Excludes hoisted script
+ expect(
+ [...allScripts].find((script) =>
+ $(script).text().includes('document.querySelector("#update-me")')
+ ),
+ '`WithScripts.astro` hoisted script included unexpectedly.'
+ ).to.be.undefined;
+ });
+
+ it('Applies MDX components export', async () => {
+ const html = await fixture.readFile('/launch-week-components-export/index.html');
+ const $ = cheerio.load(html);
+
+ const h2 = $('h2');
+ expect(h2).to.have.a.lengthOf(1);
+ expect(h2.attr('data-components-export-applied')).to.equal('true');
+ });
+ });
+
+ describe('Build - SSR', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ output: 'server',
+ root: './fixtures/content/',
+ adapter: testAdapter(),
+ // test suite was authored when inlineStylesheets defaulted to never
+ build: { inlineStylesheets: 'never' },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Includes CSS for rendered entry', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/launch-week');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // Renders content
+ expect($('ul li')).to.have.a.lengthOf(3);
+
+ // Includes styles
+ expect($('link[rel=stylesheet]')).to.have.a.lengthOf(1);
+ });
+
+ it('Exclude CSS for non-rendered entries', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // Includes styles
+ expect($('link[rel=stylesheet]')).to.have.a.lengthOf(0);
+ });
+
+ it('De-duplicates CSS used both in layout and directly in target page', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/with-layout-prop/');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const set = new Set();
+
+ $('link[rel=stylesheet]').each((_, linkEl) => {
+ const href = linkEl.attribs.href;
+ expect(set).to.not.contain(href);
+ set.add(href);
+ });
+
+ $('style').each((_, styleEl) => {
+ const textContent = styleEl.children[0].data;
+ expect(set).to.not.contain(textContent);
+ set.add(textContent);
+ });
+ });
+
+ it('Applies MDX components export', async () => {
+ const app = await fixture.loadTestAdapterApp();
+ const request = new Request('http://example.com/launch-week-components-export');
+ const response = await app.render(request);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const h2 = $('h2');
+ expect(h2).to.have.a.lengthOf(1);
+ expect(h2.attr('data-components-export-applied')).to.equal('true');
+ });
+
+ it('getCollection should return new instances of the array to be mutated safely', async () => {
+ const app = await fixture.loadTestAdapterApp();
+
+ let request = new Request('http://example.com/sort-blog-collection');
+ let response = await app.render(request);
+ let html = await response.text();
+ let $ = cheerio.load(html);
+ expect($('li').first().text()).to.equal('With Layout Prop');
+
+ request = new Request('http://example.com/');
+ response = await app.render(request);
+ html = await response.text();
+ $ = cheerio.load(html);
+ expect($('li').first().text()).to.equal('Hello world');
+ });
+ });
+
+ describe('Dev - SSG', () => {
+ let devServer;
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content/',
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Includes CSS for rendered entry', async () => {
+ const response = await fixture.fetch('/launch-week', { method: 'GET' });
+ expect(response.status).to.equal(200);
+
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // Renders content
+ expect($('ul li')).to.have.a.lengthOf(3);
+
+ // Includes styles
+ expect($('head > style')).to.have.a.lengthOf(1);
+ expect($('head > style').text()).to.include("font-family: 'Comic Sans MS'");
+ });
+
+ it('Includes component scripts for rendered entry', async () => {
+ const response = await fixture.fetch('/launch-week-component-scripts', { method: 'GET' });
+ expect(response.status).to.equal(200);
+
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const allScripts = $('head > script[src]');
+ expect(allScripts).to.have.length;
+
+ // Includes hoisted script
+ expect(
+ [...allScripts].find((script) => script.attribs.src.includes('WithScripts.astro')),
+ '`WithScripts.astro` hoisted script missing from head.'
+ ).to.not.be.undefined;
+
+ // Includes inline script
+ expect($('script[data-is-inline]')).to.have.a.lengthOf(1);
+ });
+
+ it('Applies MDX components export', async () => {
+ const response = await fixture.fetch('/launch-week-components-export', { method: 'GET' });
+ expect(response.status).to.equal(200);
+
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const h2 = $('h2');
+ expect(h2).to.have.a.lengthOf(1);
+ expect(h2.attr('data-components-export-applied')).to.equal('true');
+ });
+
+ it('Supports layout prop with recursive getCollection() call', async () => {
+ const response = await fixture.fetch('/with-layout-prop', { method: 'GET' });
+ expect(response.status).to.equal(200);
+
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ const body = $('body');
+ expect(body.attr('data-layout-prop')).to.equal('true');
+
+ const h1 = $('h1');
+ expect(h1).to.have.a.lengthOf(1);
+ expect(h1.text()).to.equal('With Layout Prop');
+
+ const h2 = $('h2');
+ expect(h2).to.have.a.lengthOf(1);
+ expect(h2.text()).to.equal('Content with a layout prop');
+ });
+ });
+});
diff --git a/packages/astro/test/experimental-content-collections.test.js b/packages/astro/test/experimental-content-collections.test.js
new file mode 100644
index 000000000..30ef8b20a
--- /dev/null
+++ b/packages/astro/test/experimental-content-collections.test.js
@@ -0,0 +1,372 @@
+import * as devalue from 'devalue';
+import * as cheerio from 'cheerio';
+import { expect } from 'chai';
+import { loadFixture } from './test-utils.js';
+import testAdapter from './test-adapter.js';
+import { preventNodeBuiltinDependencyPlugin } from './test-plugins.js';
+
+describe('Experimental Content Collections cache', () => {
+ describe('Query', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-collections/', experimental: { contentCollectionCache: true } });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ describe('Collection', () => {
+ let json;
+ before(async () => {
+ const rawJson = await fixture.readFile('/collections.json');
+ json = devalue.parse(rawJson);
+ });
+
+ it('Returns `without config` collection', async () => {
+ expect(json).to.haveOwnProperty('withoutConfig');
+ expect(Array.isArray(json.withoutConfig)).to.equal(true);
+
+ const ids = json.withoutConfig.map((item) => item.id).sort();
+ expect(ids).to.deep.equal([
+ 'columbia.md',
+ 'endeavour.md',
+ 'enterprise.md',
+ // Spaces allowed in IDs
+ 'promo/launch week.mdx',
+ ].sort());
+ });
+
+ it('Handles spaces in `without config` slugs', async () => {
+ expect(json).to.haveOwnProperty('withoutConfig');
+ expect(Array.isArray(json.withoutConfig)).to.equal(true);
+
+ const slugs = json.withoutConfig.map((item) => item.slug).sort();
+ expect(slugs).to.deep.equal([
+ 'columbia',
+ 'endeavour',
+ 'enterprise',
+ // "launch week.mdx" is converted to "launch-week.mdx"
+ 'promo/launch-week',
+ ].sort());
+ });
+
+ it('Returns `with schema` collection', async () => {
+ expect(json).to.haveOwnProperty('withSchemaConfig');
+ expect(Array.isArray(json.withSchemaConfig)).to.equal(true);
+
+ const ids = json.withSchemaConfig.map((item) => item.id).sort();
+ const publishedDates = json.withSchemaConfig.map((item) => item.data.publishedAt);
+
+ expect(ids).to.deep.equal(['four%.md', 'one.md', 'three.md', 'two.md']);
+ expect(publishedDates.every((date) => date instanceof Date)).to.equal(
+ true,
+ 'Not all publishedAt dates are Date objects'
+ );
+ expect(publishedDates.map((date) => date.toISOString()).sort()).to.deep.equal([
+ '2021-01-01T00:00:00.000Z',
+ '2021-01-01T00:00:00.000Z',
+ '2021-01-02T00:00:00.000Z',
+ '2021-01-03T00:00:00.000Z',
+ ]);
+ });
+
+ it('Returns `with custom slugs` collection', async () => {
+ expect(json).to.haveOwnProperty('withSlugConfig');
+ expect(Array.isArray(json.withSlugConfig)).to.equal(true);
+
+ const slugs = json.withSlugConfig.map((item) => item.slug).sort();
+ expect(slugs).to.deep.equal(['excellent-three', 'fancy-one', 'interesting-two']);
+ });
+
+ it('Returns `with union schema` collection', async () => {
+ expect(json).to.haveOwnProperty('withUnionSchema');
+ expect(Array.isArray(json.withUnionSchema)).to.equal(true);
+
+ const post = json.withUnionSchema.find((item) => item.id === 'post.md');
+ expect(post).to.not.be.undefined;
+ expect(post.data).to.deep.equal({
+ type: 'post',
+ title: 'My Post',
+ description: 'This is my post',
+ });
+ const newsletter = json.withUnionSchema.find((item) => item.id === 'newsletter.md');
+ expect(newsletter).to.not.be.undefined;
+ expect(newsletter.data).to.deep.equal({
+ type: 'newsletter',
+ subject: 'My Newsletter',
+ });
+ });
+ });
+
+ describe('Entry', () => {
+ let json;
+ before(async () => {
+ const rawJson = await fixture.readFile('/entries.json');
+ json = devalue.parse(rawJson);
+ });
+
+ it('Returns `without config` collection entry', async () => {
+ expect(json).to.haveOwnProperty('columbiaWithoutConfig');
+ expect(json.columbiaWithoutConfig.id).to.equal('columbia.md');
+ });
+
+ it('Returns `with schema` collection entry', async () => {
+ expect(json).to.haveOwnProperty('oneWithSchemaConfig');
+ expect(json.oneWithSchemaConfig.id).to.equal('one.md');
+ expect(json.oneWithSchemaConfig.data.publishedAt instanceof Date).to.equal(true);
+ expect(json.oneWithSchemaConfig.data.publishedAt.toISOString()).to.equal(
+ '2021-01-01T00:00:00.000Z'
+ );
+ });
+
+ it('Returns `with custom slugs` collection entry', async () => {
+ expect(json).to.haveOwnProperty('twoWithSlugConfig');
+ expect(json.twoWithSlugConfig.slug).to.equal('interesting-two');
+ });
+
+ it('Returns `with union schema` collection entry', async () => {
+ expect(json).to.haveOwnProperty('postWithUnionSchema');
+ expect(json.postWithUnionSchema.id).to.equal('post.md');
+ expect(json.postWithUnionSchema.data).to.deep.equal({
+ type: 'post',
+ title: 'My Post',
+ description: 'This is my post',
+ });
+ });
+ });
+ });
+
+ const blogSlugToContents = {
+ 'first-post': {
+ title: 'First post',
+ element: 'blockquote',
+ content: 'First post loaded: yes!',
+ },
+ 'second-post': {
+ title: 'Second post',
+ element: 'blockquote',
+ content: 'Second post loaded: yes!',
+ },
+ 'third-post': {
+ title: 'Third post',
+ element: 'blockquote',
+ content: 'Third post loaded: yes!',
+ },
+ 'using-mdx': {
+ title: 'Using MDX',
+ element: 'a[href="#"]',
+ content: 'Embedded component in MDX',
+ },
+ };
+
+ describe('Static paths integration', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-static-paths-integration/', experimental: {
+ contentCollectionCache: true
+ } });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Generates expected pages', async () => {
+ for (const slug in blogSlugToContents) {
+ expect(fixture.pathExists(`/posts/${slug}`)).to.equal(true);
+ }
+ });
+
+ it('Renders titles', async () => {
+ for (const slug in blogSlugToContents) {
+ const post = await fixture.readFile(`/posts/${slug}/index.html`);
+ const $ = cheerio.load(post);
+ expect($('h1').text()).to.equal(blogSlugToContents[slug].title);
+ }
+ });
+
+ it('Renders content', async () => {
+ for (const slug in blogSlugToContents) {
+ const post = await fixture.readFile(`/posts/${slug}/index.html`);
+ const $ = cheerio.load(post);
+ expect($(blogSlugToContents[slug].element).text().trim()).to.equal(
+ blogSlugToContents[slug].content
+ );
+ }
+ });
+ });
+
+ describe('With spaces in path', () => {
+ it('Does not throw', async () => {
+ const fixture = await loadFixture({ root: './fixtures/content with spaces in folder name/', experimental: {
+ contentCollectionCache: true
+ } });
+ let error = null;
+ try {
+ await fixture.build();
+ } catch (e) {
+ error = e.message;
+ } finally {
+ await fixture.clean()
+ }
+ expect(error).to.be.null;
+ });
+ });
+ describe('With config.mjs', () => {
+ it("Errors when frontmatter doesn't match schema", async () => {
+ const fixture = await loadFixture({
+ root: './fixtures/content-collections-with-config-mjs/',
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ let error;
+ try {
+ await fixture.build();
+ } catch (e) {
+ error = e.message;
+ } finally {
+ await fixture.clean()
+ }
+ expect(error).to.include('**title**: Expected type `"string"`, received "number"');
+ });
+ });
+ describe('With config.mts', () => {
+ it("Errors when frontmatter doesn't match schema", async () => {
+ const fixture = await loadFixture({
+ root: './fixtures/content-collections-with-config-mts/',
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ let error;
+ try {
+ await fixture.build();
+ } catch (e) {
+ error = e.message;
+ } finally {
+ await fixture.clean()
+ }
+ expect(error).to.include('**title**: Expected type `"string"`, received "number"');
+ });
+ });
+
+ describe('With empty markdown file', () => {
+ it('Throws the right error', async () => {
+ const fixture = await loadFixture({
+ root: './fixtures/content-collections-empty-md-file/',
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ let error;
+ try {
+ await fixture.build();
+ } catch (e) {
+ error = e.message;
+ } finally {
+ await fixture.clean()
+ }
+ expect(error).to.include('**title**: Required');
+ });
+ });
+
+ describe('With empty collections directory', () => {
+ it('Handles the empty directory correclty', async () => {
+ const fixture = await loadFixture({
+ root: './fixtures/content-collections-empty-dir/',
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ let error;
+ try {
+ await fixture.build();
+ } catch (e) {
+ error = e.message;
+ } finally {
+ await fixture.clean()
+ }
+ expect(error).to.be.undefined;
+ // TODO: try to render a page
+ });
+ });
+
+ describe('SSR integration', () => {
+ let app;
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content-ssr-integration/',
+ output: 'server',
+ adapter: testAdapter(),
+ vite: {
+ plugins: [preventNodeBuiltinDependencyPlugin()],
+ },
+ experimental: {
+ contentCollectionCache: true
+ }
+ });
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ after(() => fixture.clean());
+
+ it('Responds 200 for expected pages', async () => {
+ for (const slug in blogSlugToContents) {
+ const request = new Request('http://example.com/posts/' + slug);
+ const response = await app.render(request);
+ expect(response.status).to.equal(200);
+ }
+ });
+
+ it('Renders titles', async () => {
+ for (const slug in blogSlugToContents) {
+ const request = new Request('http://example.com/posts/' + slug);
+ const response = await app.render(request);
+ const body = await response.text();
+ const $ = cheerio.load(body);
+ expect($('h1').text()).to.equal(blogSlugToContents[slug].title);
+ }
+ });
+
+ it('Renders content', async () => {
+ for (const slug in blogSlugToContents) {
+ const request = new Request('http://example.com/posts/' + slug);
+ const response = await app.render(request);
+ const body = await response.text();
+ const $ = cheerio.load(body);
+ expect($(blogSlugToContents[slug].element).text().trim()).to.equal(
+ blogSlugToContents[slug].content
+ );
+ }
+ });
+ });
+
+ describe('Base configuration', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content-collections-base/',
+ });
+ await fixture.build();
+ });
+
+ after(() => fixture.clean());
+
+ it('Includes base in links', async () => {
+ const html = await fixture.readFile('/docs/index.html');
+ const $ = cheerio.load(html);
+ expect($('link').attr('href')).to.satisfies((a) => a.startsWith('/docs'));
+ });
+
+ it('Includes base in hoisted scripts', async () => {
+ const html = await fixture.readFile('/docs/index.html');
+ const $ = cheerio.load(html);
+ expect($('script').attr('src')).to.satisfies((a) => a.startsWith('/docs'));
+ });
+ });
+});
diff --git a/packages/astro/test/fixtures/content-collection-references/src/pages/welcome-data.json.js b/packages/astro/test/fixtures/content-collection-references/src/pages/welcome-data.json.js
index a461a1a65..27f97174d 100644
--- a/packages/astro/test/fixtures/content-collection-references/src/pages/welcome-data.json.js
+++ b/packages/astro/test/fixtures/content-collection-references/src/pages/welcome-data.json.js
@@ -14,12 +14,10 @@ export async function GET() {
const rawRelatedPosts = await getEntries(welcomePost.data.relatedPosts ?? []);
const relatedPosts = rawRelatedPosts.map(({ render /** filter out render() function */, ...p }) => p);
- return {
- body: JSON.stringify({
- welcomePost,
- banner,
- author,
- relatedPosts,
- })
- }
+ return Response.json({
+ welcomePost,
+ banner,
+ author,
+ relatedPosts,
+ })
}
diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js
index 8316d2fa5..213c98711 100644
--- a/packages/astro/test/test-utils.js
+++ b/packages/astro/test/test-utils.js
@@ -204,6 +204,14 @@ export async function loadFixture(inlineConfig) {
recursive: true,
force: true,
});
+ const contentCache = new URL('./node_modules/.astro/content', config.root);
+ if (fs.existsSync(contentCache)) {
+ await fs.promises.rm(contentCache, {
+ maxRetries: 10,
+ recursive: true,
+ force: true,
+ });
+ }
},
loadTestAdapterApp: async (streaming) => {
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir);
diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js
index f8d6dce24..d81e42a72 100644
--- a/packages/astro/test/units/test-utils.js
+++ b/packages/astro/test/units/test-utils.js
@@ -78,6 +78,14 @@ class VirtualVolumeWithFallback extends VirtualVolume {
}
});
}
+
+ readFileSync(p, ...args) {
+ try {
+ return super.readFileSync(p, ...args);
+ } catch (e) {
+ return realFS.readFileSync(p, ...args);
+ }
+ }
}
export function createFs(json, root, VolumeImpl = VirtualVolume) {
diff --git a/scripts/cmd/copy.js b/scripts/cmd/copy.js
index 1e64a793d..377a60a39 100644
--- a/scripts/cmd/copy.js
+++ b/scripts/cmd/copy.js
@@ -65,7 +65,7 @@ export default async function copy() {
const dest = resolve(file.replace(/^[^/]+/, 'dist'));
return fs
.mkdir(dirname(dest), { recursive: true })
- .then(() => fs.copyFile(resolve(file), dest));
+ .then(() => fs.copyFile(resolve(file), dest, fs.constants.COPYFILE_FICLONE));
})
);
}