diff options
Diffstat (limited to '')
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)); }) ); } |