diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index b97133dde..5ff5c8e6c 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -14,6 +14,7 @@ import { type GlobResult = Record Promise>; type CollectionToEntryMap = Record; +type GetEntryImport = (collection: string, lookupId: string) => () => Promise; export function createCollectionToGlobResultMap({ globResult, @@ -28,9 +29,8 @@ export function createCollectionToGlobResultMap({ const segments = keyRelativeToContentDir.split('/'); if (segments.length <= 1) continue; const collection = segments[0]; - const entryId = segments.slice(1).join('/'); collectionToGlobResultMap[collection] ??= {}; - collectionToGlobResultMap[collection][entryId] = globResult[key]; + collectionToGlobResultMap[collection][key] = globResult[key]; } return collectionToGlobResultMap; } @@ -38,10 +38,10 @@ export function createCollectionToGlobResultMap({ const cacheEntriesByCollection = new Map(); export function createGetCollection({ collectionToEntryMap, - collectionToRenderEntryMap, + getRenderEntryImport, }: { collectionToEntryMap: CollectionToEntryMap; - collectionToRenderEntryMap: CollectionToEntryMap; + getRenderEntryImport: GetEntryImport; }) { return async function getCollection(collection: string, filter?: (entry: any) => unknown) { const lazyImports = Object.values(collectionToEntryMap[collection] ?? {}); @@ -64,7 +64,7 @@ export function createGetCollection({ return render({ collection: entry.collection, id: entry.id, - collectionToRenderEntryMap, + renderEntryImport: await getRenderEntryImport(collection, entry.slug), }); }, }; @@ -82,10 +82,10 @@ export function createGetCollection({ export function createGetEntryBySlug({ getCollection, - collectionToRenderEntryMap, + getRenderEntryImport, }: { getCollection: ReturnType; - collectionToRenderEntryMap: CollectionToEntryMap; + getRenderEntryImport: GetEntryImport; }) { return async function getEntryBySlug(collection: string, slug: string) { // This is not an optimized lookup. Should look into an O(1) implementation @@ -114,7 +114,7 @@ export function createGetEntryBySlug({ return render({ collection: entry.collection, id: entry.id, - collectionToRenderEntryMap, + renderEntryImport: await getRenderEntryImport(collection, entry.slug), }); }, }; @@ -124,21 +124,20 @@ export function createGetEntryBySlug({ async function render({ collection, id, - collectionToRenderEntryMap, + renderEntryImport, }: { collection: string; id: string; - collectionToRenderEntryMap: CollectionToEntryMap; + renderEntryImport?: ReturnType; }) { const UnexpectedRenderError = new AstroError({ ...AstroErrorData.UnknownContentCollectionError, message: `Unexpected error while rendering ${String(collection)} → ${String(id)}.`, }); - const lazyImport = collectionToRenderEntryMap[collection]?.[id]; - if (typeof lazyImport !== 'function') throw UnexpectedRenderError; + if (typeof renderEntryImport !== 'function') throw UnexpectedRenderError; - const baseMod = await lazyImport(); + const baseMod = await renderEntryImport(); if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError; const { collectedStyles, collectedLinks, collectedScripts, getMod } = baseMod; diff --git a/packages/astro/src/content/template/virtual-mod.mjs b/packages/astro/src/content/template/virtual-mod.mjs index 5e04ac5e7..7ebbf881b 100644 --- a/packages/astro/src/content/template/virtual-mod.mjs +++ b/packages/astro/src/content/template/virtual-mod.mjs @@ -28,6 +28,16 @@ const collectionToEntryMap = createCollectionToGlobResultMap({ contentDir, }); +function createGlobLookup(entryGlob) { + return async (collection, lookupId) => { + const { default: lookupMap } = await import('@@LOOKUP_MAP_PATH@@'); + const filePath = lookupMap[collection]?.[lookupId]; + + if (!filePath) return undefined; + return entryGlob[collection][filePath]; + }; +} + const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', { query: { astroPropagatedAssets: true }, }); @@ -38,10 +48,10 @@ const collectionToRenderEntryMap = createCollectionToGlobResultMap({ export const getCollection = createGetCollection({ collectionToEntryMap, - collectionToRenderEntryMap, + getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap), }); export const getEntryBySlug = createGetEntryBySlug({ getCollection, - collectionToRenderEntryMap, + getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap), }); diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 825f7b5d8..9cd3022cc 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -22,6 +22,7 @@ import { type ContentObservable, type ContentPaths, type EntryInfo, + updateLookupMaps, } from './utils.js'; type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; @@ -278,6 +279,12 @@ export async function createContentTypesGenerator({ contentConfig: observable.status === 'loaded' ? observable.config : undefined, contentEntryTypes: settings.contentEntryTypes, }); + await updateLookupMaps({ + contentEntryExts, + contentPaths, + root: settings.config.root, + fs, + }); if (observable.status === 'loaded' && ['info', 'warn'].includes(logLevel)) { warnNonexistentCollections({ logging, diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index f392efc55..9ad57195d 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -1,3 +1,4 @@ +import glob, { type Options as FastGlobOptions } from 'fast-glob'; import { slug as githubSlug } from 'github-slugger'; import matter from 'gray-matter'; import fsMod from 'node:fs'; @@ -12,6 +13,7 @@ import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { CONTENT_TYPES_FILE } from './consts.js'; import { errorMap } from './error-map.js'; import { createImage } from './runtime-assets.js'; +import { rootRelativePath } from '../core/util.js'; export const collectionConfigParser = z.object({ schema: z.any().optional(), @@ -129,16 +131,23 @@ export function getContentEntryExts(settings: Pick & { entry: URL; allowFilesOutsideCollection?: true } + params: Pick & { + entry: string | URL; + allowFilesOutsideCollection?: true; + } ): EntryInfo; export function getEntryInfo({ entry, contentDir, allowFilesOutsideCollection = false, -}: Pick & { entry: URL; allowFilesOutsideCollection?: boolean }): - | EntryInfo - | NoCollectionError { - const rawRelativePath = path.relative(fileURLToPath(contentDir), fileURLToPath(entry)); +}: Pick & { + entry: string | URL; + allowFilesOutsideCollection?: boolean; +}): EntryInfo | NoCollectionError { + const rawRelativePath = path.relative( + fileURLToPath(contentDir), + typeof entry === 'string' ? entry : fileURLToPath(entry) + ); const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift(); const isOutsideCollection = rawCollection === '..' || rawCollection === '.'; @@ -358,3 +367,51 @@ function search(fs: typeof fsMod, srcDir: URL) { } return { exists: false, url: paths[0] }; } + +export async function updateLookupMaps({ + contentPaths, + contentEntryExts, + root, + fs, +}: { + contentEntryExts: string[]; + contentPaths: Pick; + root: URL; + fs: typeof fsMod; +}) { + const { contentDir } = contentPaths; + const globOpts: FastGlobOptions = { + absolute: false, + cwd: fileURLToPath(root), + fs: { + readdir: fs.readdir.bind(fs), + readdirSync: fs.readdirSync.bind(fs), + }, + }; + + const relContentDir = rootRelativePath(root, contentDir, false); + const contentGlob = await glob(`${relContentDir}/**/*${getExtGlob(contentEntryExts)}`, globOpts); + let filePathByLookupId: { + [collection: string]: Record; + } = {}; + + for (const filePath of contentGlob) { + const info = getEntryInfo({ contentDir, entry: filePath }); + if (info instanceof NoCollectionError) continue; + filePathByLookupId[info.collection] ??= {}; + // TODO: frontmatter slugs + filePathByLookupId[info.collection][info.slug] = '/' + filePath; + } + + await fs.promises.writeFile( + new URL('lookup-map.json', contentPaths.cacheDir), + JSON.stringify(filePathByLookupId, null, 2) + ); +} + +export function getExtGlob(exts: string[]) { + return exts.length === 1 + ? // Wrapping {...} breaks when there is only one extension + exts[0] + : `{${exts.join(',')}}`; +} 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 3a72bf1de..cf8f03fa7 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -1,11 +1,9 @@ import fsMod from 'node:fs'; -import * as path from 'node:path'; import type { Plugin } from 'vite'; -import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro.js'; -import { appendForwardSlash, prependForwardSlash } from '../core/path.js'; import { VIRTUAL_MODULE_ID } from './consts.js'; -import { getContentEntryExts, getContentPaths } from './utils.js'; +import { getContentEntryExts, getContentPaths, getExtGlob } from './utils.js'; +import { rootRelativePath } from '../core/util.js'; interface AstroContentVirtualModPluginParams { settings: AstroSettings; @@ -15,13 +13,7 @@ export function astroContentVirtualModPlugin({ settings, }: AstroContentVirtualModPluginParams): Plugin { const contentPaths = getContentPaths(settings.config); - const relContentDir = normalizePath( - appendForwardSlash( - prependForwardSlash( - path.relative(settings.config.root.pathname, contentPaths.contentDir.pathname) - ) - ) - ); + const relContentDir = rootRelativePath(settings.config.root, contentPaths.contentDir); const contentEntryExts = getContentEntryExts(settings); const extGlob = @@ -32,6 +24,7 @@ export function astroContentVirtualModPlugin({ const entryGlob = `${relContentDir}**/*${extGlob}`; const virtualModContents = fsMod .readFileSync(contentPaths.virtualModTemplate, 'utf-8') + .replace('@@LOOKUP_MAP_PATH@@', new URL('lookup-map.json', contentPaths.cacheDir).pathname) .replace('@@CONTENT_DIR@@', relContentDir) .replace('@@ENTRY_GLOB_PATH@@', entryGlob) .replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob); diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 5d8868115..25a2d133d 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -151,18 +151,15 @@ export function relativeToSrcDir(config: AstroConfig, idOrUrl: URL | string) { return id.slice(slash(fileURLToPath(config.srcDir)).length); } -export function rootRelativePath(root: URL, idOrUrl: URL | string) { +export function rootRelativePath(root: URL, idOrUrl: URL | string, prependSlash = true) { let id: string; if (typeof idOrUrl !== 'string') { id = unwrapId(viteID(idOrUrl)); } else { id = idOrUrl; } - const normalizedRoot = normalizePath(fileURLToPath(root)); - if (id.startsWith(normalizedRoot)) { - id = id.slice(normalizedRoot.length); - } - return prependForwardSlash(id); + id = id.slice(normalizePath(fileURLToPath(root)).length); + return prependSlash ? prependForwardSlash(id) : id; } export function emoji(char: string, fallback: string) {