From 4361f68e52af39e4e639ddb63f689f05be22b498 Mon Sep 17 00:00:00 2001 From: bholmesdev Date: Mon, 5 Dec 2022 09:33:13 -0500 Subject: [PATCH] feat: custom slugs + better type checking --- packages/astro/src/content/internal.ts | 78 +------- .../src/content/template/types.generated.d.ts | 19 +- .../src/content/template/types.generated.mjs | 11 -- packages/astro/src/content/utils.ts | 158 ++++++++++++++++ .../astro/src/content/vite-plugin-content.ts | 177 ++++++++++-------- packages/astro/src/core/create-vite.ts | 11 +- 6 files changed, 287 insertions(+), 167 deletions(-) create mode 100644 packages/astro/src/content/utils.ts diff --git a/packages/astro/src/content/internal.ts b/packages/astro/src/content/internal.ts index 29ff09f2380e2..00088970eeb95 100644 --- a/packages/astro/src/content/internal.ts +++ b/packages/astro/src/content/internal.ts @@ -1,9 +1,7 @@ -import { z } from 'zod'; import { prependForwardSlash } from '../core/path.js'; type GlobResult = Record Promise>; type CollectionToEntryMap = Record; -type CollectionsConfig = Record; export function createCollectionToGlobResultMap({ globResult, @@ -25,90 +23,22 @@ export function createCollectionToGlobResultMap({ return collectionToGlobResultMap; } -export async function parseEntryData( - collection: string, - entry: { id: string; data: any; _internal: { rawData: string; filePath: string } }, - collectionsConfig: CollectionsConfig -) { - if (!('schema' in (collectionsConfig[collection] ?? {}))) { - throw new Error(getErrorMsg.schemaDefMissing(collection)); - } - const { schema } = collectionsConfig[collection]; - // Use `safeParseAsync` to allow async transforms - const parsed = await z.object(schema).safeParseAsync(entry.data, { errorMap }); - - if (parsed.success) { - return parsed.data; - } else { - const formattedError = new Error( - [ - `Could not parse frontmatter in ${String(collection)} → ${String(entry.id)}`, - ...parsed.error.errors.map((zodError) => zodError.message), - ].join('\n') - ); - (formattedError as any).loc = { - file: entry._internal.filePath, - line: getFrontmatterErrorLine( - entry._internal.rawData, - String(parsed.error.errors[0].path[0]) - ), - column: 1, - }; - throw formattedError; - } -} - -const flattenPath = (path: (string | number)[]) => path.join('.'); - -const errorMap: z.ZodErrorMap = (error, ctx) => { - if (error.code === 'invalid_type') { - const badKeyPath = JSON.stringify(flattenPath(error.path)); - if (error.received === 'undefined') { - return { message: `${badKeyPath} is required.` }; - } else { - return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` }; - } - } - return { message: ctx.defaultError }; -}; - -// WARNING: MAXIMUM JANK AHEAD -function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) { - const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`); - if (indexOfFrontmatterKey === -1) return 0; - - const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1); - const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length; - return numNewlinesBeforeKey; -} - -export const getErrorMsg = { - schemaFileMissing: (collection: string) => - `${collection} does not have a config. We suggest adding one for type safety!`, - schemaDefMissing: (collection: string) => - `${collection} needs a schema definition. Check your src/content/config!`, -}; - export function createGetCollection({ collectionToEntryMap, - getCollectionsConfig, }: { collectionToEntryMap: CollectionToEntryMap; - getCollectionsConfig: () => Promise; }) { return async function getCollection(collection: string, filter?: () => boolean) { const lazyImports = Object.values(collectionToEntryMap[collection] ?? {}); - const collectionsConfig = await getCollectionsConfig(); const entries = Promise.all( lazyImports.map(async (lazyImport) => { const entry = await lazyImport(); - const data = await parseEntryData(collection, entry, collectionsConfig); return { id: entry.id, slug: entry.slug, body: entry.body, collection: entry.collection, - data, + data: entry.data, }; }) ); @@ -122,24 +52,20 @@ export function createGetCollection({ export function createGetEntry({ collectionToEntryMap, - getCollectionsConfig, }: { collectionToEntryMap: CollectionToEntryMap; - getCollectionsConfig: () => Promise; }) { return async function getEntry(collection: string, entryId: string) { const lazyImport = collectionToEntryMap[collection]?.[entryId]; - const collectionsConfig = await getCollectionsConfig(); if (!lazyImport) throw new Error(`Ah! ${entryId}`); const entry = await lazyImport(); - const data = await parseEntryData(collection, entry, collectionsConfig); return { id: entry.id, slug: entry.slug, body: entry.body, collection: entry.collection, - data, + data: entry.data, }; }; } diff --git a/packages/astro/src/content/template/types.generated.d.ts b/packages/astro/src/content/template/types.generated.d.ts index a7645cee64817..42b3221ea6f22 100644 --- a/packages/astro/src/content/template/types.generated.d.ts +++ b/packages/astro/src/content/template/types.generated.d.ts @@ -6,8 +6,19 @@ declare module 'astro:content' { collection: C ): Promise; - type BaseCollectionConfig = { schema: import('astro/zod').ZodRawShape }; - export function defineCollection(input: C): C; + type BaseCollectionConfig = { + schema?: S; + slug?: (entry: { + id: CollectionEntry['id']; + defaultSlug: CollectionEntry['slug']; + collection: string; + body: string; + data: import('astro/zod').infer>; + }) => string | Promise; + }; + export function defineCollection( + input: BaseCollectionConfig + ): BaseCollectionConfig; export function getEntry( collection: C, @@ -33,12 +44,12 @@ declare module 'astro:content' { }>; type InferEntrySchema = import('astro/zod').infer< - import('astro/zod').ZodObject + import('astro/zod').ZodObject['schema']> >; const entryMap: { // @@ENTRY_MAP@@ }; - type CollectionsConfig = typeof import('@@COLLECTIONS_IMPORT_PATH@@'); + type ContentConfig = '@@CONTENT_CONFIG_TYPE@@'; } diff --git a/packages/astro/src/content/template/types.generated.mjs b/packages/astro/src/content/template/types.generated.mjs index 4ad4585ca3f0e..7e1cf45565409 100644 --- a/packages/astro/src/content/template/types.generated.mjs +++ b/packages/astro/src/content/template/types.generated.mjs @@ -22,14 +22,6 @@ const collectionToEntryMap = createCollectionToGlobResultMap({ contentDir, }); -async function getCollectionsConfig() { - const res = await import('@@COLLECTIONS_IMPORT_PATH@@'); - if ('collections' in res) { - return res.collections; - } - return {}; -} - const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', { query: { astroAssetSsr: true }, }); @@ -40,13 +32,10 @@ const collectionToRenderEntryMap = createCollectionToGlobResultMap({ export const getCollection = createGetCollection({ collectionToEntryMap, - getCollectionsConfig, }); export const getEntry = createGetEntry({ collectionToEntryMap, - getCollectionsConfig, - contentDir, }); export const renderEntry = createRenderEntry({ collectionToRenderEntryMap }); diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts new file mode 100644 index 0000000000000..f0a3610ac35d4 --- /dev/null +++ b/packages/astro/src/content/utils.ts @@ -0,0 +1,158 @@ +import matter from 'gray-matter'; +import { z } from 'zod'; +import { createServer, ErrorPayload as ViteErrorPayload, ViteDevServer } from 'vite'; +import { astroContentVirtualModPlugin, getPaths } from './vite-plugin-content.js'; +import { AstroSettings } from '../@types/astro.js'; + +export const collectionConfigParser = z.object({ + schema: z.any().optional(), + slug: z + .function() + .args( + z.object({ + id: z.string(), + collection: z.string(), + defaultSlug: z.string(), + body: z.string(), + data: z.record(z.any()), + }) + ) + .returns(z.union([z.string(), z.promise(z.string())])) + .optional(), +}); + +export const contentConfigParser = z.object({ + collections: z.record(collectionConfigParser), +}); + +export type CollectionConfig = z.infer; +export type ContentConfig = z.infer; + +type Entry = { + id: string; + collection: string; + slug: string; + data: any; + body: string; + _internal: { rawData: string; filePath: string }; +}; + +export const msg = { + collectionConfigMissing: (collection: string) => + `${collection} does not have a config. We suggest adding one for type safety!`, +}; + +export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) { + return ( + collectionConfig.slug?.({ + id: entry.id, + data: entry.data, + defaultSlug: entry.slug, + collection: entry.collection, + body: entry.body, + }) ?? entry.slug + ); +} + +export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) { + let data = entry.data; + if (collectionConfig.schema) { + // Use `safeParseAsync` to allow async transforms + const parsed = await z.object(collectionConfig.schema).safeParseAsync(entry.data, { errorMap }); + if (parsed.success) { + data = parsed.data; + } else { + const formattedError = new Error( + [ + `Could not parse frontmatter in ${String(entry.collection)} → ${String(entry.id)}`, + ...parsed.error.errors.map((zodError) => zodError.message), + ].join('\n') + ); + (formattedError as any).loc = { + file: entry._internal.filePath, + line: getFrontmatterErrorLine( + entry._internal.rawData, + String(parsed.error.errors[0].path[0]) + ), + column: 1, + }; + throw formattedError; + } + } + return data; +} + +const flattenPath = (path: (string | number)[]) => path.join('.'); + +const errorMap: z.ZodErrorMap = (error, ctx) => { + if (error.code === 'invalid_type') { + const badKeyPath = JSON.stringify(flattenPath(error.path)); + if (error.received === 'undefined') { + return { message: `${badKeyPath} is required.` }; + } else { + return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` }; + } + } + return { message: ctx.defaultError }; +}; + +// WARNING: MAXIMUM JANK AHEAD +function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) { + const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`); + if (indexOfFrontmatterKey === -1) return 0; + + const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1); + const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length; + return numNewlinesBeforeKey; +} + +/** + * Match YAML exception handling from Astro core errors + * @see 'astro/src/core/errors.ts' + */ +export function parseFrontmatter(fileContents: string, filePath: string) { + try { + return matter(fileContents); + } catch (e: any) { + if (e.name === 'YAMLException') { + const err: Error & ViteErrorPayload['err'] = e; + err.id = filePath; + err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column }; + err.message = e.reason; + throw err; + } else { + throw e; + } + } +} + +export async function loadContentConfig({ + settings, +}: { + settings: AstroSettings; +}): Promise { + const paths = getPaths({ srcDir: settings.config.srcDir }); + const tempConfigServer: ViteDevServer = await createServer({ + root: settings.config.root.pathname, + server: { middlewareMode: true, hmr: false }, + optimizeDeps: { entries: [] }, + clearScreen: false, + appType: 'custom', + logLevel: 'silent', + plugins: [astroContentVirtualModPlugin({ settings })], + }); + let unparsedConfig; + try { + unparsedConfig = await tempConfigServer.ssrLoadModule(paths.config.pathname); + } catch { + return new Error('Failed to resolve content config.'); + } finally { + await tempConfigServer.close(); + } + const config = contentConfigParser.safeParse(unparsedConfig); + if (config.success) { + return config.data; + } else { + return new TypeError('Content config file is invalid.'); + } +} diff --git a/packages/astro/src/content/vite-plugin-content.ts b/packages/astro/src/content/vite-plugin-content.ts index 4519a3fc943ab..6a2d1f529cbf1 100644 --- a/packages/astro/src/content/vite-plugin-content.ts +++ b/packages/astro/src/content/vite-plugin-content.ts @@ -1,16 +1,23 @@ -import { Plugin, ErrorPayload as ViteErrorPayload, normalizePath } from 'vite'; +import { Plugin, normalizePath } from 'vite'; import glob from 'fast-glob'; import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; import { bold, cyan } from 'kleur/colors'; -import matter from 'gray-matter'; import { info, LogOptions, warn } from '../core/logger/core.js'; import type { AstroSettings } from '../@types/astro.js'; import { appendForwardSlash, prependForwardSlash } from '../core/path.js'; import { contentFileExts, CONTENT_FLAG, VIRTUAL_MODULE_ID } from './consts.js'; import { escapeViteEnvReferences } from '../vite-plugin-utils/index.js'; import { pathToFileURL } from 'node:url'; +import { + CollectionConfig, + ContentConfig, + getEntryData, + getEntrySlug, + parseFrontmatter, +} from './utils.js'; +import * as devalue from 'devalue'; type Paths = { contentDir: URL; @@ -27,24 +34,18 @@ function isContentFlagImport({ searchParams, pathname }: Pick pathname.endsWith(ext)); } -export function astroContentPlugin({ - settings, - logging, -}: { - logging: LogOptions; - settings: AstroSettings; -}): Plugin[] { - const { root, srcDir } = settings.config; - const paths: Paths = { +export function getPaths({ srcDir }: { srcDir: URL }): Paths { + return { // Output generated types in content directory. May change in the future! cacheDir: new URL('./content/', srcDir), contentDir: new URL('./content/', srcDir), generatedInputDir: new URL('../../src/content/template/', import.meta.url), config: new URL('./content/config', srcDir), }; - let contentDirExists = false; - let contentGenerator: GenerateContent; +} +export function astroContentVirtualModPlugin({ settings }: { settings: AstroSettings }): Plugin { + const paths = getPaths({ srcDir: settings.config.srcDir }); const relContentDir = appendForwardSlash( prependForwardSlash(path.relative(settings.config.root.pathname, paths.contentDir.pathname)) ); @@ -53,42 +54,74 @@ export function astroContentPlugin({ .readFileSync(new URL(CONTENT_FILE, paths.generatedInputDir), 'utf-8') .replace('@@CONTENT_DIR@@', relContentDir) .replace('@@ENTRY_GLOB_PATH@@', entryGlob) - .replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob) - .replace('@@COLLECTIONS_IMPORT_PATH@@', paths.config.pathname); + .replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob); - const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; + const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; - return [ - { - name: 'astro-content-virtual-module-plugin', - resolveId(id) { - if (id === VIRTUAL_MODULE_ID) { - return resolvedVirtualModuleId; - } - }, - load(id) { - if (id === resolvedVirtualModuleId) { - return { - code: astroContentModContents, - }; - } - }, + return { + name: 'astro-content-virtual-mod-plugin', + enforce: 'pre', + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return astroContentVirtualModuleId; + } + }, + load(id) { + if (id === astroContentVirtualModuleId) { + return { + code: astroContentModContents, + }; + } }, + }; +} + +export function astroContentServerPlugin({ + settings, + contentConfig, + logging, +}: { + logging: LogOptions; + settings: AstroSettings; + contentConfig: ContentConfig | Error; +}): Plugin[] { + const paths: Paths = getPaths({ srcDir: settings.config.srcDir }); + let contentDirExists = false; + let contentGenerator: GenerateContent; + return [ { name: 'content-flag-plugin', async load(id) { const { pathname, searchParams } = new URL(id, 'file://'); if (isContentFlagImport({ pathname, searchParams })) { const rawContents = await fs.readFile(pathname, 'utf-8'); - const { content: body, data, matter: rawData } = parseFrontmatter(rawContents, pathname); - const entryInfo = parseEntryInfo(pathname, { contentDir: paths.contentDir }); + const { + content: body, + data: unparsedData, + matter: rawData, + } = parseFrontmatter(rawContents, pathname); + const entryInfo = getEntryInfo({ entryPath: pathname, contentDir: paths.contentDir }); if (entryInfo instanceof Error) return; + + const _internal = { filePath: pathname, rawData }; + const partialEntry = { data: unparsedData, body, _internal, ...entryInfo }; + const collectionConfig = + contentConfig instanceof Error + ? undefined + : contentConfig.collections[entryInfo.collection]; + const data = collectionConfig + ? await getEntryData(partialEntry, collectionConfig) + : unparsedData; + const slug = collectionConfig + ? await getEntrySlug({ ...partialEntry, data }, collectionConfig) + : entryInfo.slug; + const code = escapeViteEnvReferences(` export const id = ${JSON.stringify(entryInfo.id)}; export const collection = ${JSON.stringify(entryInfo.collection)}; -export const slug = ${JSON.stringify(entryInfo.slug)}; +export const slug = ${JSON.stringify(slug)}; export const body = ${JSON.stringify(body)}; -export const data = ${JSON.stringify(data)}; +export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */}; export const _internal = { filePath: ${JSON.stringify(pathname)}, rawData: ${JSON.stringify(rawData)}, @@ -106,7 +139,7 @@ export const _internal = { }, }, { - name: 'astro-fetch-content-plugin', + name: 'astro-content-server-plugin', async config() { try { await fs.stat(paths.contentDir); @@ -118,7 +151,7 @@ export const _internal = { info(logging, 'content', 'Generating entries...'); - contentGenerator = await toGenerateContent({ logging, paths }); + contentGenerator = await toGenerateContent({ logging, paths, contentConfig }); await contentGenerator.init(); }, async configureServer(viteServer) { @@ -126,7 +159,9 @@ export const _internal = { info( logging, 'content', - `Watching ${cyan(paths.contentDir.href.replace(root.href, ''))} for changes` + `Watching ${cyan( + paths.contentDir.href.replace(settings.config.root.href, '') + )} for changes` ); attachListeners(); } else { @@ -184,9 +219,11 @@ const msg = { async function toGenerateContent({ logging, paths, + contentConfig, }: { logging: LogOptions; paths: Paths; + contentConfig: ContentConfig | Error; }): Promise { const contentTypes: ContentTypes = {}; @@ -242,20 +279,22 @@ async function toGenerateContent({ ); return; } - const entryInfo = parseEntryInfo(event.entry, paths); + const entryInfo = getEntryInfo({ entryPath: event.entry, contentDir: paths.contentDir }); // Not a valid `src/content/` entry. Silently return, but should be impossible? if (entryInfo instanceof Error) return; const { id, slug, collection } = entryInfo; const collectionKey = JSON.stringify(collection); const entryKey = JSON.stringify(id); + const collectionConfig = + contentConfig instanceof Error ? undefined : contentConfig.collections[collection]; switch (event.name) { case 'add': if (!(collectionKey in contentTypes)) { addCollection(contentTypes, collectionKey); } if (!(entryKey in contentTypes[collectionKey])) { - addEntry(contentTypes, collectionKey, entryKey, slug); + addEntry(contentTypes, collectionKey, entryKey, slug, collectionConfig); } if (shouldLog) { info(logging, 'content', msg.entryAdded(entryInfo.slug, entryInfo.collection)); @@ -291,6 +330,7 @@ async function toGenerateContent({ contentTypes, paths, contentTypesBase, + hasContentConfig: !(contentConfig instanceof Error), }); resolve(); }, 50 /* debounce 50 ms to batch chokidar events */); @@ -314,21 +354,29 @@ function addEntry( contentTypes: ContentTypes, collectionKey: string, entryKey: string, - slug: string + slug: string, + collectionConfig?: CollectionConfig ) { - contentTypes[collectionKey][entryKey] = `{\n id: ${entryKey},\n slug: ${JSON.stringify( - slug - )},\n body: string,\n collection: ${collectionKey},\n data: InferEntrySchema<${collectionKey}>\n}`; + const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any'; + // If user has custom slug function, we can't predict slugs at type compilation. + // Would require parsing all data and evaluating ahead-of-time; + // We evaluate with lazy imports at dev server runtime + // to prevent excessive errors + const slugType = collectionConfig?.slug ? 'string' : JSON.stringify(slug); + + contentTypes[collectionKey][ + entryKey + ] = `{\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n}`; } function removeEntry(contentTypes: ContentTypes, collectionKey: string, entryKey: string) { delete contentTypes[collectionKey][entryKey]; } -function parseEntryInfo( - entryPath: string, - { contentDir }: Pick -): EntryInfo | Error { +function getEntryInfo({ + entryPath, + contentDir, +}: Pick & { entryPath: string }): EntryInfo | Error { const relativeEntryPath = normalizePath(path.relative(contentDir.pathname, entryPath)); const collection = path.dirname(relativeEntryPath).split(path.sep).shift(); if (!collection) return new Error(); @@ -354,51 +402,32 @@ function getEntryType(entryPath: string, paths: Paths): 'content' | 'config' | ' } } -/** - * Match YAML exception handling from Astro core errors - * @see 'astro/src/core/errors.ts' - */ -function parseFrontmatter(fileContents: string, filePath: string) { - try { - return matter(fileContents); - } catch (e: any) { - if (e.name === 'YAMLException') { - const err: Error & ViteErrorPayload['err'] = e; - err.id = filePath; - err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column }; - err.message = e.reason; - throw err; - } else { - throw e; - } - } -} - -function stringifyObjKeyValue(key: string, value: string) { - return `${key}: ${value},\n`; -} - async function writeContentFiles({ paths, contentTypes, contentTypesBase, + hasContentConfig, }: { paths: Paths; contentTypes: ContentTypes; contentTypesBase: string; + hasContentConfig: boolean; }) { let contentTypesStr = ''; for (const collectionKey in contentTypes) { contentTypesStr += `${collectionKey}: {\n`; for (const entryKey in contentTypes[collectionKey]) { const entry = contentTypes[collectionKey][entryKey]; - contentTypesStr += stringifyObjKeyValue(entryKey, entry); + contentTypesStr += `${entryKey}: ${entry},\n`; } contentTypesStr += `},\n`; } contentTypesBase = contentTypesBase.replace('// @@ENTRY_MAP@@', contentTypesStr); - contentTypesBase = contentTypesBase.replace('@@COLLECTIONS_IMPORT_PATH@@', paths.config.pathname); + contentTypesBase = contentTypesBase.replace( + "'@@CONTENT_CONFIG_TYPE@@'", + hasContentConfig ? `typeof import(${JSON.stringify(paths.config.pathname)})` : 'never' + ); try { await fs.stat(paths.cacheDir); diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 76031e27e4fd4..46f7b3644c733 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -20,8 +20,12 @@ import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import { createCustomViteLogger } from './errors/dev/index.js'; import { resolveDependency } from './util.js'; -import { astroContentPlugin } from '../content/vite-plugin-content.js'; +import { + astroContentServerPlugin, + astroContentVirtualModPlugin, +} from '../content/vite-plugin-content.js'; import { astroDelayedAssetPlugin } from '../content/vite-plugin-delayed-assets.js'; +import { loadContentConfig } from '../content/utils.js'; interface CreateViteOptions { settings: AstroSettings; @@ -87,6 +91,8 @@ export async function createVite( }, }); + const contentConfig = await loadContentConfig({ settings }); + // Start with the Vite configuration that Astro core needs const commonConfig: vite.InlineConfig = { cacheDir: fileURLToPath(new URL('./node_modules/.vite/', settings.config.root)), // using local caches allows Astro to be used in monorepos, etc. @@ -114,7 +120,8 @@ export async function createVite( astroPostprocessVitePlugin({ settings }), astroIntegrationsContainerPlugin({ settings, logging }), astroScriptsPageSSRPlugin({ settings }), - astroContentPlugin({ settings, logging }), + astroContentVirtualModPlugin({ settings }), + astroContentServerPlugin({ settings, logging, contentConfig }), astroDelayedAssetPlugin({ settings, mode }), ], publicDir: fileURLToPath(settings.config.publicDir),