diff --git a/.changeset/lovely-pianos-build.md b/.changeset/lovely-pianos-build.md new file mode 100644 index 000000000000..d7d512f3cfa1 --- /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 9ce06960f773..137e442521d8 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 597043d49490..9aa7dc89b6e0 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 5d3951e2482b..02e3a3bb31f9 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 8919dfc40976..1a7d5aa52048 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 9bfb2e86512a..f65652453b60 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 e507149feaa1..5ca396da8c30 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -19,6 +19,11 @@ type GlobResult = Record; type CollectionToEntryMap = Record; type GetEntryImport = (collection: string, lookupId: string) => Promise; +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 5834c81d82e7..e95e167c8611 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 133fc9eddf22..8dafc1be8a49 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(); let entryScripts = new Set(); - 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(); @@ -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 d7924973cc24..1f4f7eead352 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; - dataEntryExts: string[]; - contentPaths: Pick; - 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 83e45f808371..fc315ff7dba2 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(); 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 a6bcf8b17990..0960760a42d9 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 { 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 ce517485b7e7..1dc38e73566b 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; + cachedClientEntries: string[]; + + propagatedStylesMap: Map>; + propagatedScriptsMap: Map>; + // A list of all static files created during the build. Used for SSR. staticFiles: Set; // 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>(); 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 c5da47457076..ef1207e3f667 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>; type OutputChunkorAsset = RollupOutputArray[number]['output'][number]; type OutputChunk = Extract; +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 }) => { - enforce?: 'after-user-plugins'; - vitePlugin: VitePlugin | VitePlugin[] | undefined; - }; + 'build:before'?: (opts: { target: BuildTarget; input: Set }) => BuildBeforeHookResult | Promise; '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(); const allPlugins = new Set(); + 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) { - let plugins = build === 'ssr' ? ssrPlugins : clientPlugins; + async runBeforeHook(target: BuildTarget, input: Set) { + let targetPlugins = plugins.get(target) ?? []; let vitePlugins: Array = []; let lastVitePlugins: Array = []; - 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 90620cb28a1e..3e6a5e6d6f1c 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 052ea45b7668..6fb09acf8c43 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 b81932dce96a..b99624a869ed 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 000000000000..3a2767ef1ef2 --- /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 01e480e2fdb4..bfa2ce58ce9b 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 000000000000..537fbbc124fa --- /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(); +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(newEntries); + if (oldVersion !== newVersion || oldEntries.length === 0) { + entries.buildFromSource = Array.from(newEntryMap.keys()); + return entries; + } + const oldEntryHashMap = new Map(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 { + let manifest: ContentManifest = { version: CONTENT_MANIFEST_VERSION, entries: [], serverEntries: [], clientEntries: [] }; + const limit = pLimit(10); + const promises: Promise[] = []; + + 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 5e24f85d21c7..dd5d0af03ba8 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 5c6b40992f09..1c02f7adcae6 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 ab79e5f47982..4f18e0245886 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, 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 2e912eab5095..337719163168 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 bfc72e1a0003..701422a5b767 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 e72e38440c8c..2a348f18fece 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 35e4813fc468..d3d5305e4f43 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 6cb45dc59bbd..c5853dca5c6c 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 14152f6fb86a..fd892c9b63c7 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 fa79d72bdf3d..4ab36fb23873 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; type OutputOptions = Parameters[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 43727b876f16..e80c288c0042 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, 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 f289f019b805..e3551f73a722 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 d8fdd9bb095e..53acb6924fcb 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 e10aa4b754ca..13b31c7e0173 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 fda837209753..de644372996c 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 a82b50ff8431..68c61bcc6d29 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 58722fdb2cb1..228e4e437fcd 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 000000000000..ae55fc9de746 --- /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 000000000000..10dee6d9f9f5 --- /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