[Content collections] Fast getEntryBySlug()
lookup (#6916)
* feat: add generated lookup-map * feat: wire up fast getEntryBySlug() lookup * fix: consider frontmatter slugs * chore: changeset * chore: lint no-shadow * fix: revert bad rootRelativePath change * chore: better var name * refactor: generated `.json` to in-memory map * chore: removed unneeded await Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> * chore: removed unneeded await Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> * Revert "chore: removed unneeded await" This reverts commit1b0a8b00c2
. * fix: bad `GetEntryImport` type * chore: remove unused variable * refactor: for -> Promise.all * refactor: replace duplicate parseSlug * refactor: add cache layer * Revert "refactor: add cache layer" This reverts commit1c3bfdc6b3
. * refactor: add comment, move to virtual-mod * chore: add jsdocs * refactor: fiiiiine no more `??=` * fix: ignore underscore files in lookup map * chore: add unknowncollectionerror on bad file contents --------- Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
This commit is contained in:
parent
1b90a7a5d5
commit
630f8c8ef6
9 changed files with 268 additions and 107 deletions
5
.changeset/friendly-poets-breathe.md
Normal file
5
.changeset/friendly-poets-breathe.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add fast lookups for content collection entries when using `getEntryBySlug()`. This generates a lookup map to ensure O(1) retrieval.
|
|
@ -11,8 +11,10 @@ import {
|
||||||
unescapeHTML,
|
unescapeHTML,
|
||||||
} from '../runtime/server/index.js';
|
} from '../runtime/server/index.js';
|
||||||
|
|
||||||
type GlobResult = Record<string, () => Promise<any>>;
|
type LazyImport = () => Promise<any>;
|
||||||
|
type GlobResult = Record<string, LazyImport>;
|
||||||
type CollectionToEntryMap = Record<string, GlobResult>;
|
type CollectionToEntryMap = Record<string, GlobResult>;
|
||||||
|
type GetEntryImport = (collection: string, lookupId: string) => Promise<LazyImport>;
|
||||||
|
|
||||||
export function createCollectionToGlobResultMap({
|
export function createCollectionToGlobResultMap({
|
||||||
globResult,
|
globResult,
|
||||||
|
@ -27,9 +29,8 @@ export function createCollectionToGlobResultMap({
|
||||||
const segments = keyRelativeToContentDir.split('/');
|
const segments = keyRelativeToContentDir.split('/');
|
||||||
if (segments.length <= 1) continue;
|
if (segments.length <= 1) continue;
|
||||||
const collection = segments[0];
|
const collection = segments[0];
|
||||||
const entryId = segments.slice(1).join('/');
|
|
||||||
collectionToGlobResultMap[collection] ??= {};
|
collectionToGlobResultMap[collection] ??= {};
|
||||||
collectionToGlobResultMap[collection][entryId] = globResult[key];
|
collectionToGlobResultMap[collection][key] = globResult[key];
|
||||||
}
|
}
|
||||||
return collectionToGlobResultMap;
|
return collectionToGlobResultMap;
|
||||||
}
|
}
|
||||||
|
@ -37,10 +38,10 @@ export function createCollectionToGlobResultMap({
|
||||||
const cacheEntriesByCollection = new Map<string, any[]>();
|
const cacheEntriesByCollection = new Map<string, any[]>();
|
||||||
export function createGetCollection({
|
export function createGetCollection({
|
||||||
collectionToEntryMap,
|
collectionToEntryMap,
|
||||||
collectionToRenderEntryMap,
|
getRenderEntryImport,
|
||||||
}: {
|
}: {
|
||||||
collectionToEntryMap: CollectionToEntryMap;
|
collectionToEntryMap: CollectionToEntryMap;
|
||||||
collectionToRenderEntryMap: CollectionToEntryMap;
|
getRenderEntryImport: GetEntryImport;
|
||||||
}) {
|
}) {
|
||||||
return async function getCollection(collection: string, filter?: (entry: any) => unknown) {
|
return async function getCollection(collection: string, filter?: (entry: any) => unknown) {
|
||||||
const lazyImports = Object.values(collectionToEntryMap[collection] ?? {});
|
const lazyImports = Object.values(collectionToEntryMap[collection] ?? {});
|
||||||
|
@ -63,7 +64,7 @@ export function createGetCollection({
|
||||||
return render({
|
return render({
|
||||||
collection: entry.collection,
|
collection: entry.collection,
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
collectionToRenderEntryMap,
|
renderEntryImport: await getRenderEntryImport(collection, entry.slug),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -80,29 +81,18 @@ export function createGetCollection({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGetEntryBySlug({
|
export function createGetEntryBySlug({
|
||||||
getCollection,
|
getEntryImport,
|
||||||
collectionToRenderEntryMap,
|
getRenderEntryImport,
|
||||||
}: {
|
}: {
|
||||||
getCollection: ReturnType<typeof createGetCollection>;
|
getEntryImport: GetEntryImport;
|
||||||
collectionToRenderEntryMap: CollectionToEntryMap;
|
getRenderEntryImport: GetEntryImport;
|
||||||
}) {
|
}) {
|
||||||
return async function getEntryBySlug(collection: string, slug: string) {
|
return async function getEntryBySlug(collection: string, slug: string) {
|
||||||
// This is not an optimized lookup. Should look into an O(1) implementation
|
const entryImport = await getEntryImport(collection, slug);
|
||||||
// as it's probably that people will have very large collections.
|
if (typeof entryImport !== 'function') return undefined;
|
||||||
const entries = await getCollection(collection);
|
|
||||||
let candidate: (typeof entries)[number] | undefined = undefined;
|
|
||||||
for (let entry of entries) {
|
|
||||||
if (entry.slug === slug) {
|
|
||||||
candidate = entry;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof candidate === 'undefined') {
|
const entry = await entryImport();
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = candidate;
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
slug: entry.slug,
|
slug: entry.slug,
|
||||||
|
@ -113,7 +103,7 @@ export function createGetEntryBySlug({
|
||||||
return render({
|
return render({
|
||||||
collection: entry.collection,
|
collection: entry.collection,
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
collectionToRenderEntryMap,
|
renderEntryImport: await getRenderEntryImport(collection, slug),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -123,21 +113,20 @@ export function createGetEntryBySlug({
|
||||||
async function render({
|
async function render({
|
||||||
collection,
|
collection,
|
||||||
id,
|
id,
|
||||||
collectionToRenderEntryMap,
|
renderEntryImport,
|
||||||
}: {
|
}: {
|
||||||
collection: string;
|
collection: string;
|
||||||
id: string;
|
id: string;
|
||||||
collectionToRenderEntryMap: CollectionToEntryMap;
|
renderEntryImport?: LazyImport;
|
||||||
}) {
|
}) {
|
||||||
const UnexpectedRenderError = new AstroError({
|
const UnexpectedRenderError = new AstroError({
|
||||||
...AstroErrorData.UnknownContentCollectionError,
|
...AstroErrorData.UnknownContentCollectionError,
|
||||||
message: `Unexpected error while rendering ${String(collection)} → ${String(id)}.`,
|
message: `Unexpected error while rendering ${String(collection)} → ${String(id)}.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lazyImport = collectionToRenderEntryMap[collection]?.[id];
|
if (typeof renderEntryImport !== 'function') throw UnexpectedRenderError;
|
||||||
if (typeof lazyImport !== 'function') throw UnexpectedRenderError;
|
|
||||||
|
|
||||||
const baseMod = await lazyImport();
|
const baseMod = await renderEntryImport();
|
||||||
if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError;
|
if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError;
|
||||||
|
|
||||||
const { collectedStyles, collectedLinks, collectedScripts, getMod } = baseMod;
|
const { collectedStyles, collectedLinks, collectedScripts, getMod } = baseMod;
|
||||||
|
|
|
@ -28,6 +28,18 @@ const collectionToEntryMap = createCollectionToGlobResultMap({
|
||||||
contentDir,
|
contentDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let lookupMap = {};
|
||||||
|
/* @@LOOKUP_MAP_ASSIGNMENT@@ */
|
||||||
|
|
||||||
|
function createGlobLookup(glob) {
|
||||||
|
return async (collection, lookupId) => {
|
||||||
|
const filePath = lookupMap[collection]?.[lookupId];
|
||||||
|
|
||||||
|
if (!filePath) return undefined;
|
||||||
|
return glob[collection][filePath];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
|
const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
|
||||||
query: { astroPropagatedAssets: true },
|
query: { astroPropagatedAssets: true },
|
||||||
});
|
});
|
||||||
|
@ -38,10 +50,10 @@ const collectionToRenderEntryMap = createCollectionToGlobResultMap({
|
||||||
|
|
||||||
export const getCollection = createGetCollection({
|
export const getCollection = createGetCollection({
|
||||||
collectionToEntryMap,
|
collectionToEntryMap,
|
||||||
collectionToRenderEntryMap,
|
getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getEntryBySlug = createGetEntryBySlug({
|
export const getEntryBySlug = createGetEntryBySlug({
|
||||||
getCollection,
|
getEntryImport: createGlobLookup(collectionToEntryMap),
|
||||||
collectionToRenderEntryMap,
|
getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap),
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,20 +8,19 @@ import type { AstroSettings, ContentEntryType } from '../@types/astro.js';
|
||||||
import { AstroError, AstroErrorData } from '../core/errors/index.js';
|
import { AstroError, AstroErrorData } from '../core/errors/index.js';
|
||||||
import { info, warn, type LogOptions } from '../core/logger/core.js';
|
import { info, warn, type LogOptions } from '../core/logger/core.js';
|
||||||
import { isRelativePath } from '../core/path.js';
|
import { isRelativePath } from '../core/path.js';
|
||||||
import { CONTENT_TYPES_FILE } from './consts.js';
|
import { CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js';
|
||||||
import {
|
import {
|
||||||
getContentEntryExts,
|
|
||||||
getContentPaths,
|
getContentPaths,
|
||||||
getEntryInfo,
|
getEntryInfo,
|
||||||
getEntrySlug,
|
|
||||||
getEntryType,
|
getEntryType,
|
||||||
loadContentConfig,
|
loadContentConfig,
|
||||||
NoCollectionError,
|
NoCollectionError,
|
||||||
parseFrontmatter,
|
|
||||||
type ContentConfig,
|
type ContentConfig,
|
||||||
type ContentObservable,
|
type ContentObservable,
|
||||||
type ContentPaths,
|
type ContentPaths,
|
||||||
type EntryInfo,
|
type EntryInfo,
|
||||||
|
getContentEntryConfigByExtMap,
|
||||||
|
getEntrySlug,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
|
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
|
||||||
|
@ -58,7 +57,8 @@ export async function createContentTypesGenerator({
|
||||||
}: CreateContentGeneratorParams) {
|
}: CreateContentGeneratorParams) {
|
||||||
const contentTypes: ContentTypes = {};
|
const contentTypes: ContentTypes = {};
|
||||||
const contentPaths = getContentPaths(settings.config, fs);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
const contentEntryExts = getContentEntryExts(settings);
|
const contentEntryConfigByExt = getContentEntryConfigByExtMap(settings);
|
||||||
|
const contentEntryExts = [...contentEntryConfigByExt.keys()];
|
||||||
|
|
||||||
let events: EventWithOptions[] = [];
|
let events: EventWithOptions[] = [];
|
||||||
let debounceTimeout: NodeJS.Timeout | undefined;
|
let debounceTimeout: NodeJS.Timeout | undefined;
|
||||||
|
@ -186,14 +186,23 @@ export async function createContentTypesGenerator({
|
||||||
return { shouldGenerateTypes: false };
|
return { shouldGenerateTypes: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, collection } = entryInfo;
|
const { id, collection, slug: generatedSlug } = entryInfo;
|
||||||
|
const contentEntryType = contentEntryConfigByExt.get(path.extname(event.entry.pathname));
|
||||||
|
if (!contentEntryType) return { shouldGenerateTypes: false };
|
||||||
|
|
||||||
const collectionKey = JSON.stringify(collection);
|
const collectionKey = JSON.stringify(collection);
|
||||||
const entryKey = JSON.stringify(id);
|
const entryKey = JSON.stringify(id);
|
||||||
|
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
case 'add':
|
case 'add':
|
||||||
const addedSlug = await parseSlug({ fs, event, entryInfo });
|
const addedSlug = await getEntrySlug({
|
||||||
|
generatedSlug,
|
||||||
|
id,
|
||||||
|
collection,
|
||||||
|
fileUrl: event.entry,
|
||||||
|
contentEntryType,
|
||||||
|
fs,
|
||||||
|
});
|
||||||
if (!(collectionKey in contentTypes)) {
|
if (!(collectionKey in contentTypes)) {
|
||||||
addCollection(contentTypes, collectionKey);
|
addCollection(contentTypes, collectionKey);
|
||||||
}
|
}
|
||||||
|
@ -209,7 +218,14 @@ export async function createContentTypesGenerator({
|
||||||
case 'change':
|
case 'change':
|
||||||
// User may modify `slug` in their frontmatter.
|
// User may modify `slug` in their frontmatter.
|
||||||
// Only regen types if this change is detected.
|
// Only regen types if this change is detected.
|
||||||
const changedSlug = await parseSlug({ fs, event, entryInfo });
|
const changedSlug = await getEntrySlug({
|
||||||
|
generatedSlug,
|
||||||
|
id,
|
||||||
|
collection,
|
||||||
|
fileUrl: event.entry,
|
||||||
|
contentEntryType,
|
||||||
|
fs,
|
||||||
|
});
|
||||||
if (contentTypes[collectionKey]?.[entryKey]?.slug !== changedSlug) {
|
if (contentTypes[collectionKey]?.[entryKey]?.slug !== changedSlug) {
|
||||||
setEntry(contentTypes, collectionKey, entryKey, changedSlug);
|
setEntry(contentTypes, collectionKey, entryKey, changedSlug);
|
||||||
return { shouldGenerateTypes: true };
|
return { shouldGenerateTypes: true };
|
||||||
|
@ -278,6 +294,7 @@ export async function createContentTypesGenerator({
|
||||||
contentConfig: observable.status === 'loaded' ? observable.config : undefined,
|
contentConfig: observable.status === 'loaded' ? observable.config : undefined,
|
||||||
contentEntryTypes: settings.contentEntryTypes,
|
contentEntryTypes: settings.contentEntryTypes,
|
||||||
});
|
});
|
||||||
|
invalidateVirtualMod(viteServer);
|
||||||
if (observable.status === 'loaded' && ['info', 'warn'].includes(logLevel)) {
|
if (observable.status === 'loaded' && ['info', 'warn'].includes(logLevel)) {
|
||||||
warnNonexistentCollections({
|
warnNonexistentCollections({
|
||||||
logging,
|
logging,
|
||||||
|
@ -290,6 +307,15 @@ export async function createContentTypesGenerator({
|
||||||
return { init, queueEvent };
|
return { init, queueEvent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The virtual module contains a lookup map from slugs to content imports.
|
||||||
|
// Invalidate whenever content types change.
|
||||||
|
function invalidateVirtualMod(viteServer: ViteDevServer) {
|
||||||
|
const virtualMod = viteServer.moduleGraph.getModuleById('\0' + VIRTUAL_MODULE_ID);
|
||||||
|
if (!virtualMod) return;
|
||||||
|
|
||||||
|
viteServer.moduleGraph.invalidateModule(virtualMod);
|
||||||
|
}
|
||||||
|
|
||||||
function addCollection(contentMap: ContentTypes, collectionKey: string) {
|
function addCollection(contentMap: ContentTypes, collectionKey: string) {
|
||||||
contentMap[collectionKey] = {};
|
contentMap[collectionKey] = {};
|
||||||
}
|
}
|
||||||
|
@ -298,25 +324,6 @@ function removeCollection(contentMap: ContentTypes, collectionKey: string) {
|
||||||
delete contentMap[collectionKey];
|
delete contentMap[collectionKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseSlug({
|
|
||||||
fs,
|
|
||||||
event,
|
|
||||||
entryInfo,
|
|
||||||
}: {
|
|
||||||
fs: typeof fsMod;
|
|
||||||
event: ContentEvent;
|
|
||||||
entryInfo: EntryInfo;
|
|
||||||
}) {
|
|
||||||
// `slug` may be present in entry frontmatter.
|
|
||||||
// This should be respected by the generated `slug` type!
|
|
||||||
// Parse frontmatter and retrieve `slug` value for this.
|
|
||||||
// Note: will raise any YAML exceptions and `slug` parse errors (i.e. `slug` is a boolean)
|
|
||||||
// on dev server startup or production build init.
|
|
||||||
const rawContents = await fs.promises.readFile(event.entry, 'utf-8');
|
|
||||||
const { data: frontmatter } = parseFrontmatter(rawContents, fileURLToPath(event.entry));
|
|
||||||
return getEntrySlug({ ...entryInfo, unvalidatedSlug: frontmatter.slug });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEntry(
|
function setEntry(
|
||||||
contentTypes: ContentTypes,
|
contentTypes: ContentTypes,
|
||||||
collectionKey: string,
|
collectionKey: string,
|
||||||
|
|
|
@ -6,7 +6,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import type { PluginContext } from 'rollup';
|
import type { PluginContext } from 'rollup';
|
||||||
import { normalizePath, type ErrorPayload as ViteErrorPayload, type ViteDevServer } from 'vite';
|
import { normalizePath, type ErrorPayload as ViteErrorPayload, type ViteDevServer } from 'vite';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AstroConfig, AstroSettings, ImageInputFormat } from '../@types/astro.js';
|
import type {
|
||||||
|
AstroConfig,
|
||||||
|
AstroSettings,
|
||||||
|
ContentEntryType,
|
||||||
|
ImageInputFormat,
|
||||||
|
} from '../@types/astro.js';
|
||||||
import { VALID_INPUT_FORMATS } from '../assets/consts.js';
|
import { VALID_INPUT_FORMATS } from '../assets/consts.js';
|
||||||
import { AstroError, AstroErrorData } from '../core/errors/index.js';
|
import { AstroError, AstroErrorData } from '../core/errors/index.js';
|
||||||
import { CONTENT_TYPES_FILE } from './consts.js';
|
import { CONTENT_TYPES_FILE } from './consts.js';
|
||||||
|
@ -45,14 +50,19 @@ export const msg = {
|
||||||
`${collection} does not have a config. We suggest adding one for type safety!`,
|
`${collection} does not have a config. We suggest adding one for type safety!`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getEntrySlug({
|
export function parseEntrySlug({
|
||||||
id,
|
id,
|
||||||
collection,
|
collection,
|
||||||
slug,
|
generatedSlug,
|
||||||
unvalidatedSlug,
|
frontmatterSlug,
|
||||||
}: EntryInfo & { unvalidatedSlug?: unknown }) {
|
}: {
|
||||||
|
id: string;
|
||||||
|
collection: string;
|
||||||
|
generatedSlug: string;
|
||||||
|
frontmatterSlug?: unknown;
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
return z.string().default(slug).parse(unvalidatedSlug);
|
return z.string().default(generatedSlug).parse(frontmatterSlug);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AstroError({
|
throw new AstroError({
|
||||||
...AstroErrorData.InvalidContentEntrySlugError,
|
...AstroErrorData.InvalidContentEntrySlugError,
|
||||||
|
@ -126,19 +136,36 @@ export function getContentEntryExts(settings: Pick<AstroSettings, 'contentEntryT
|
||||||
return settings.contentEntryTypes.map((t) => t.extensions).flat();
|
return settings.contentEntryTypes.map((t) => t.extensions).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getContentEntryConfigByExtMap(settings: Pick<AstroSettings, 'contentEntryTypes'>) {
|
||||||
|
const map: Map<string, ContentEntryType> = new Map();
|
||||||
|
for (const entryType of settings.contentEntryTypes) {
|
||||||
|
for (const ext of entryType.extensions) {
|
||||||
|
map.set(ext, entryType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
export class NoCollectionError extends Error {}
|
export class NoCollectionError extends Error {}
|
||||||
|
|
||||||
export function getEntryInfo(
|
export function getEntryInfo(
|
||||||
params: Pick<ContentPaths, 'contentDir'> & { entry: URL; allowFilesOutsideCollection?: true }
|
params: Pick<ContentPaths, 'contentDir'> & {
|
||||||
|
entry: string | URL;
|
||||||
|
allowFilesOutsideCollection?: true;
|
||||||
|
}
|
||||||
): EntryInfo;
|
): EntryInfo;
|
||||||
export function getEntryInfo({
|
export function getEntryInfo({
|
||||||
entry,
|
entry,
|
||||||
contentDir,
|
contentDir,
|
||||||
allowFilesOutsideCollection = false,
|
allowFilesOutsideCollection = false,
|
||||||
}: Pick<ContentPaths, 'contentDir'> & { entry: URL; allowFilesOutsideCollection?: boolean }):
|
}: Pick<ContentPaths, 'contentDir'> & {
|
||||||
| EntryInfo
|
entry: string | URL;
|
||||||
| NoCollectionError {
|
allowFilesOutsideCollection?: boolean;
|
||||||
const rawRelativePath = path.relative(fileURLToPath(contentDir), fileURLToPath(entry));
|
}): EntryInfo | NoCollectionError {
|
||||||
|
const rawRelativePath = path.relative(
|
||||||
|
fileURLToPath(contentDir),
|
||||||
|
typeof entry === 'string' ? entry : fileURLToPath(entry)
|
||||||
|
);
|
||||||
const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift();
|
const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift();
|
||||||
const isOutsideCollection = rawCollection === '..' || rawCollection === '.';
|
const isOutsideCollection = rawCollection === '..' || rawCollection === '.';
|
||||||
|
|
||||||
|
@ -200,7 +227,7 @@ function isImageAsset(fileExt: string) {
|
||||||
return VALID_INPUT_FORMATS.includes(fileExt.slice(1) as ImageInputFormat);
|
return VALID_INPUT_FORMATS.includes(fileExt.slice(1) as ImageInputFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasUnderscoreBelowContentDirectoryPath(
|
export function hasUnderscoreBelowContentDirectoryPath(
|
||||||
fileUrl: URL,
|
fileUrl: URL,
|
||||||
contentDir: ContentPaths['contentDir']
|
contentDir: ContentPaths['contentDir']
|
||||||
): boolean {
|
): boolean {
|
||||||
|
@ -358,3 +385,43 @@ function search(fs: typeof fsMod, srcDir: URL) {
|
||||||
}
|
}
|
||||||
return { exists: false, url: paths[0] };
|
return { exists: false, url: paths[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for slug in content entry frontmatter and validate the type,
|
||||||
|
* falling back to the `generatedSlug` if none is found.
|
||||||
|
*/
|
||||||
|
export async function getEntrySlug({
|
||||||
|
id,
|
||||||
|
collection,
|
||||||
|
generatedSlug,
|
||||||
|
contentEntryType,
|
||||||
|
fileUrl,
|
||||||
|
fs,
|
||||||
|
}: {
|
||||||
|
fs: typeof fsMod;
|
||||||
|
id: string;
|
||||||
|
collection: string;
|
||||||
|
generatedSlug: string;
|
||||||
|
fileUrl: URL;
|
||||||
|
contentEntryType: Pick<ContentEntryType, 'getEntryInfo'>;
|
||||||
|
}) {
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = await fs.promises.readFile(fileUrl, 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
// File contents should exist. Raise unexpected error as "unknown" if not.
|
||||||
|
throw new AstroError(AstroErrorData.UnknownContentCollectionError, { cause: e });
|
||||||
|
}
|
||||||
|
const { slug: frontmatterSlug } = await contentEntryType.getEntryInfo({
|
||||||
|
fileUrl,
|
||||||
|
contents: await fs.promises.readFile(fileUrl, 'utf-8'),
|
||||||
|
});
|
||||||
|
return parseEntrySlug({ generatedSlug, frontmatterSlug, id, collection });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExtGlob(exts: string[]) {
|
||||||
|
return exts.length === 1
|
||||||
|
? // Wrapping {...} breaks when there is only one extension
|
||||||
|
exts[0]
|
||||||
|
: `{${exts.join(',')}}`;
|
||||||
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
SCRIPTS_PLACEHOLDER,
|
SCRIPTS_PLACEHOLDER,
|
||||||
STYLES_PLACEHOLDER,
|
STYLES_PLACEHOLDER,
|
||||||
} from './consts.js';
|
} from './consts.js';
|
||||||
import { getContentEntryExts } from './utils.js';
|
|
||||||
|
|
||||||
function isPropagatedAsset(viteId: string) {
|
function isPropagatedAsset(viteId: string) {
|
||||||
const flags = new URLSearchParams(viteId.split('?')[1]);
|
const flags = new URLSearchParams(viteId.split('?')[1]);
|
||||||
|
@ -31,7 +30,6 @@ export function astroContentAssetPropagationPlugin({
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
}): Plugin {
|
}): Plugin {
|
||||||
let devModuleLoader: ModuleLoader;
|
let devModuleLoader: ModuleLoader;
|
||||||
const contentEntryExts = getContentEntryExts(settings);
|
|
||||||
return {
|
return {
|
||||||
name: 'astro:content-asset-propagation',
|
name: 'astro:content-asset-propagation',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
|
|
|
@ -14,11 +14,12 @@ import {
|
||||||
getContentPaths,
|
getContentPaths,
|
||||||
getEntryData,
|
getEntryData,
|
||||||
getEntryInfo,
|
getEntryInfo,
|
||||||
getEntrySlug,
|
parseEntrySlug,
|
||||||
getEntryType,
|
getEntryType,
|
||||||
globalContentConfigObserver,
|
globalContentConfigObserver,
|
||||||
NoCollectionError,
|
NoCollectionError,
|
||||||
type ContentConfig,
|
type ContentConfig,
|
||||||
|
getContentEntryConfigByExtMap,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
function isContentFlagImport(viteId: string) {
|
function isContentFlagImport(viteId: string) {
|
||||||
|
@ -55,12 +56,7 @@ export function astroContentImportPlugin({
|
||||||
const contentPaths = getContentPaths(settings.config, fs);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
const contentEntryExts = getContentEntryExts(settings);
|
const contentEntryExts = getContentEntryExts(settings);
|
||||||
|
|
||||||
const contentEntryExtToParser: Map<string, ContentEntryType> = new Map();
|
const contentEntryConfigByExt = getContentEntryConfigByExtMap(settings);
|
||||||
for (const entryType of settings.contentEntryTypes) {
|
|
||||||
for (const ext of entryType.extensions) {
|
|
||||||
contentEntryExtToParser.set(ext, entryType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins: Plugin[] = [
|
const plugins: Plugin[] = [
|
||||||
{
|
{
|
||||||
|
@ -196,7 +192,7 @@ export function astroContentImportPlugin({
|
||||||
const contentConfig = await getContentConfigFromGlobal();
|
const contentConfig = await getContentConfigFromGlobal();
|
||||||
const rawContents = await fs.promises.readFile(fileId, 'utf-8');
|
const rawContents = await fs.promises.readFile(fileId, 'utf-8');
|
||||||
const fileExt = extname(fileId);
|
const fileExt = extname(fileId);
|
||||||
if (!contentEntryExtToParser.has(fileExt)) {
|
if (!contentEntryConfigByExt.has(fileExt)) {
|
||||||
throw new AstroError({
|
throw new AstroError({
|
||||||
...AstroErrorData.UnknownContentCollectionError,
|
...AstroErrorData.UnknownContentCollectionError,
|
||||||
message: `No parser found for content entry ${JSON.stringify(
|
message: `No parser found for content entry ${JSON.stringify(
|
||||||
|
@ -204,13 +200,13 @@ export function astroContentImportPlugin({
|
||||||
)}. Did you apply an integration for this file type?`,
|
)}. Did you apply an integration for this file type?`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const contentEntryParser = contentEntryExtToParser.get(fileExt)!;
|
const contentEntryConfig = contentEntryConfigByExt.get(fileExt)!;
|
||||||
const {
|
const {
|
||||||
rawData,
|
rawData,
|
||||||
body,
|
body,
|
||||||
slug: unvalidatedSlug,
|
slug: frontmatterSlug,
|
||||||
data: unvalidatedData,
|
data: unvalidatedData,
|
||||||
} = await contentEntryParser.getEntryInfo({
|
} = await contentEntryConfig.getEntryInfo({
|
||||||
fileUrl: pathToFileURL(fileId),
|
fileUrl: pathToFileURL(fileId),
|
||||||
contents: rawContents,
|
contents: rawContents,
|
||||||
});
|
});
|
||||||
|
@ -225,7 +221,12 @@ export function astroContentImportPlugin({
|
||||||
const _internal = { filePath: fileId, rawData: rawData };
|
const _internal = { filePath: fileId, rawData: rawData };
|
||||||
// TODO: move slug calculation to the start of the build
|
// TODO: move slug calculation to the start of the build
|
||||||
// to generate a performant lookup map for `getEntryBySlug`
|
// to generate a performant lookup map for `getEntryBySlug`
|
||||||
const slug = getEntrySlug({ id, collection, slug: generatedSlug, unvalidatedSlug });
|
const slug = parseEntrySlug({
|
||||||
|
id,
|
||||||
|
collection,
|
||||||
|
generatedSlug,
|
||||||
|
frontmatterSlug,
|
||||||
|
});
|
||||||
|
|
||||||
const collectionConfig = contentConfig?.collections[collection];
|
const collectionConfig = contentConfig?.collections[collection];
|
||||||
let data = collectionConfig
|
let data = collectionConfig
|
||||||
|
|
|
@ -1,11 +1,21 @@
|
||||||
|
import glob, { type Options as FastGlobOptions } from 'fast-glob';
|
||||||
import fsMod from 'node:fs';
|
import fsMod from 'node:fs';
|
||||||
import * as path from 'node:path';
|
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
import { normalizePath } from 'vite';
|
|
||||||
import type { AstroSettings } from '../@types/astro.js';
|
import type { AstroSettings } from '../@types/astro.js';
|
||||||
import { appendForwardSlash, prependForwardSlash } from '../core/path.js';
|
|
||||||
import { VIRTUAL_MODULE_ID } from './consts.js';
|
import { VIRTUAL_MODULE_ID } from './consts.js';
|
||||||
import { getContentEntryExts, getContentPaths } from './utils.js';
|
import {
|
||||||
|
getContentEntryConfigByExtMap,
|
||||||
|
getContentPaths,
|
||||||
|
getExtGlob,
|
||||||
|
type ContentPaths,
|
||||||
|
getEntryInfo,
|
||||||
|
NoCollectionError,
|
||||||
|
getEntrySlug,
|
||||||
|
hasUnderscoreBelowContentDirectoryPath,
|
||||||
|
} from './utils.js';
|
||||||
|
import { rootRelativePath } from '../core/util.js';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { extname } from 'node:path';
|
||||||
|
|
||||||
interface AstroContentVirtualModPluginParams {
|
interface AstroContentVirtualModPluginParams {
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
|
@ -15,20 +25,12 @@ export function astroContentVirtualModPlugin({
|
||||||
settings,
|
settings,
|
||||||
}: AstroContentVirtualModPluginParams): Plugin {
|
}: AstroContentVirtualModPluginParams): Plugin {
|
||||||
const contentPaths = getContentPaths(settings.config);
|
const contentPaths = getContentPaths(settings.config);
|
||||||
const relContentDir = normalizePath(
|
const relContentDir = rootRelativePath(settings.config.root, contentPaths.contentDir);
|
||||||
appendForwardSlash(
|
|
||||||
prependForwardSlash(
|
|
||||||
path.relative(settings.config.root.pathname, contentPaths.contentDir.pathname)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const contentEntryExts = getContentEntryExts(settings);
|
|
||||||
|
|
||||||
const extGlob =
|
const contentEntryConfigByExt = getContentEntryConfigByExtMap(settings);
|
||||||
contentEntryExts.length === 1
|
const contentEntryExts = [...contentEntryConfigByExt.keys()];
|
||||||
? // Wrapping {...} breaks when there is only one extension
|
|
||||||
contentEntryExts[0]
|
const extGlob = getExtGlob(contentEntryExts);
|
||||||
: `{${contentEntryExts.join(',')}}`;
|
|
||||||
const entryGlob = `${relContentDir}**/*${extGlob}`;
|
const entryGlob = `${relContentDir}**/*${extGlob}`;
|
||||||
const virtualModContents = fsMod
|
const virtualModContents = fsMod
|
||||||
.readFileSync(contentPaths.virtualModTemplate, 'utf-8')
|
.readFileSync(contentPaths.virtualModTemplate, 'utf-8')
|
||||||
|
@ -46,12 +48,88 @@ export function astroContentVirtualModPlugin({
|
||||||
return astroContentVirtualModuleId;
|
return astroContentVirtualModuleId;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
load(id) {
|
async load(id) {
|
||||||
|
const stringifiedLookupMap = await getStringifiedLookupMap({
|
||||||
|
fs: fsMod,
|
||||||
|
contentPaths,
|
||||||
|
contentEntryConfigByExt,
|
||||||
|
root: settings.config.root,
|
||||||
|
});
|
||||||
|
|
||||||
if (id === astroContentVirtualModuleId) {
|
if (id === astroContentVirtualModuleId) {
|
||||||
return {
|
return {
|
||||||
code: virtualModContents,
|
code: virtualModContents.replace(
|
||||||
|
'/* @@LOOKUP_MAP_ASSIGNMENT@@ */',
|
||||||
|
`lookupMap = ${stringifiedLookupMap};`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a map from a collection + slug to the local file path.
|
||||||
|
* This is used internally to resolve entry imports when using `getEntryBySlug()`.
|
||||||
|
* @see `src/content/virtual-mod.mjs`
|
||||||
|
*/
|
||||||
|
export async function getStringifiedLookupMap({
|
||||||
|
contentPaths,
|
||||||
|
contentEntryConfigByExt,
|
||||||
|
root,
|
||||||
|
fs,
|
||||||
|
}: {
|
||||||
|
contentEntryConfigByExt: ReturnType<typeof getContentEntryConfigByExtMap>;
|
||||||
|
contentPaths: Pick<ContentPaths, 'contentDir' | 'cacheDir'>;
|
||||||
|
root: URL;
|
||||||
|
fs: typeof fsMod;
|
||||||
|
}) {
|
||||||
|
const { contentDir } = contentPaths;
|
||||||
|
const globOpts: FastGlobOptions = {
|
||||||
|
absolute: true,
|
||||||
|
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([...contentEntryConfigByExt.keys()])}`,
|
||||||
|
globOpts
|
||||||
|
);
|
||||||
|
let filePathByLookupId: {
|
||||||
|
[collection: string]: Record<string, string>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
contentGlob
|
||||||
|
// Ignore underscore files in lookup map
|
||||||
|
.filter((e) => !hasUnderscoreBelowContentDirectoryPath(pathToFileURL(e), contentDir))
|
||||||
|
.map(async (filePath) => {
|
||||||
|
const info = getEntryInfo({ contentDir, entry: filePath });
|
||||||
|
// Globbed entry outside a collection directory
|
||||||
|
// Log warning during type generation, safe to ignore in lookup map
|
||||||
|
if (info instanceof NoCollectionError) return;
|
||||||
|
const contentEntryType = contentEntryConfigByExt.get(extname(filePath));
|
||||||
|
if (!contentEntryType) return;
|
||||||
|
|
||||||
|
const { id, collection, slug: generatedSlug } = info;
|
||||||
|
const slug = await getEntrySlug({
|
||||||
|
id,
|
||||||
|
collection,
|
||||||
|
generatedSlug,
|
||||||
|
fs,
|
||||||
|
fileUrl: pathToFileURL(filePath),
|
||||||
|
contentEntryType,
|
||||||
|
});
|
||||||
|
filePathByLookupId[collection] = {
|
||||||
|
...filePathByLookupId[collection],
|
||||||
|
[slug]: rootRelativePath(root, filePath),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.stringify(filePathByLookupId);
|
||||||
|
}
|
||||||
|
|
|
@ -151,7 +151,11 @@ export function relativeToSrcDir(config: AstroConfig, idOrUrl: URL | string) {
|
||||||
return id.slice(slash(fileURLToPath(config.srcDir)).length);
|
return id.slice(slash(fileURLToPath(config.srcDir)).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rootRelativePath(root: URL, idOrUrl: URL | string) {
|
export function rootRelativePath(
|
||||||
|
root: URL,
|
||||||
|
idOrUrl: URL | string,
|
||||||
|
shouldPrependForwardSlash = true
|
||||||
|
) {
|
||||||
let id: string;
|
let id: string;
|
||||||
if (typeof idOrUrl !== 'string') {
|
if (typeof idOrUrl !== 'string') {
|
||||||
id = unwrapId(viteID(idOrUrl));
|
id = unwrapId(viteID(idOrUrl));
|
||||||
|
@ -162,7 +166,7 @@ export function rootRelativePath(root: URL, idOrUrl: URL | string) {
|
||||||
if (id.startsWith(normalizedRoot)) {
|
if (id.startsWith(normalizedRoot)) {
|
||||||
id = id.slice(normalizedRoot.length);
|
id = id.slice(normalizedRoot.length);
|
||||||
}
|
}
|
||||||
return prependForwardSlash(id);
|
return shouldPrependForwardSlash ? prependForwardSlash(id) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emoji(char: string, fallback: string) {
|
export function emoji(char: string, fallback: string) {
|
||||||
|
|
Loading…
Reference in a new issue