diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2d4bcfa15..54a409798 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -10,6 +10,7 @@ import type { import type * as babel from '@babel/core'; import type { OutgoingHttpHeaders } from 'http'; import type { AddressInfo } from 'net'; +import type * as rollup from 'rollup'; import type { TsConfigJson } from 'tsconfig-resolver'; import type * as vite from 'vite'; import type { z } from 'zod'; @@ -1034,12 +1035,25 @@ export interface AstroConfig extends z.output { integrations: AstroIntegration[]; } +export type ContentEntryModule = { + id: string; + collection: string; + slug: string; + body: string; + data: Record; + _internal: { + rawData: string; + filePath: string; + }; +}; + export interface ContentEntryType { extensions: string[]; getEntryInfo(params: { fileUrl: URL; contents: string; }): GetEntryInfoReturnType | Promise; + getModule?(params: { entry: ContentEntryModule }): rollup.LoadResult | Promise; contentModuleTypes?: string; } diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index c5b8e732b..cf727f9e0 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -1,6 +1,8 @@ import * as devalue from 'devalue'; import type fsMod from 'node:fs'; +import type { ContentEntryModule } from '../@types/astro.js'; import { extname } from 'node:path'; +import type { PluginContext } from 'rollup'; import { pathToFileURL } from 'url'; import type { Plugin } from 'vite'; import type { AstroSettings, ContentEntryType } from '../@types/astro.js'; @@ -10,6 +12,7 @@ import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index import { CONTENT_FLAG } from './consts.js'; import { ContentConfig, + EntryInfo, getContentEntryExts, getContentPaths, getEntryData, @@ -17,6 +20,7 @@ import { getEntrySlug, getEntryType, globalContentConfigObserver, + NoCollectionError, patchAssets, } from './utils.js'; function isContentFlagImport(viteId: string, contentEntryExts: string[]) { @@ -30,7 +34,7 @@ export function astroContentImportPlugin({ }: { fs: typeof fsMod; settings: AstroSettings; -}): Plugin { +}): Plugin[] { const contentPaths = getContentPaths(settings.config, fs); const contentEntryExts = getContentEntryExts(settings); @@ -41,116 +45,159 @@ export function astroContentImportPlugin({ } } - return { - name: 'astro:content-imports', - async load(id) { - const { fileId } = getFileInfo(id, settings.config); - if (isContentFlagImport(id, contentEntryExts)) { - const observable = globalContentConfigObserver.get(); + async function getEntryDataById( + fileId: string, + pluginContext: PluginContext + ): Promise { + const observable = globalContentConfigObserver.get(); - // Content config should be loaded before this plugin is used - if (observable.status === 'init') { - throw new AstroError({ - ...AstroErrorData.UnknownContentCollectionError, - message: 'Content config failed to load.', - }); - } - if (observable.status === 'error') { - // Throw here to bubble content config errors - // to the error overlay in development - throw observable.error; - } + // Content config should be loaded before this plugin is used + if (observable.status === 'init') { + throw new AstroError({ + ...AstroErrorData.UnknownContentCollectionError, + message: 'Content config failed to load.', + }); + } + if (observable.status === 'error') { + // Throw here to bubble content config errors + // to the error overlay in development + throw observable.error; + } - let contentConfig: ContentConfig | undefined = - observable.status === 'loaded' ? observable.config : undefined; - if (observable.status === 'loading') { - // Wait for config to load - contentConfig = await new Promise((resolve) => { - const unsubscribe = globalContentConfigObserver.subscribe((ctx) => { - if (ctx.status === 'loaded') { - resolve(ctx.config); - unsubscribe(); - } else if (ctx.status === 'error') { - resolve(undefined); - unsubscribe(); - } - }); - }); - } - const rawContents = await fs.promises.readFile(fileId, 'utf-8'); - const fileExt = extname(fileId); - if (!contentEntryExtToParser.has(fileExt)) { - throw new AstroError({ - ...AstroErrorData.UnknownContentCollectionError, - message: `No parser found for content entry ${JSON.stringify( - fileId - )}. Did you apply an integration for this file type?`, - }); - } - const contentEntryParser = contentEntryExtToParser.get(fileExt)!; - const info = await contentEntryParser.getEntryInfo({ - fileUrl: pathToFileURL(fileId), - contents: rawContents, + let contentConfig: ContentConfig | undefined = + observable.status === 'loaded' ? observable.config : undefined; + if (observable.status === 'loading') { + // Wait for config to load + contentConfig = await new Promise((resolve) => { + const unsubscribe = globalContentConfigObserver.subscribe((ctx) => { + if (ctx.status === 'loaded') { + resolve(ctx.config); + unsubscribe(); + } else if (ctx.status === 'error') { + resolve(undefined); + unsubscribe(); + } }); - const generatedInfo = getEntryInfo({ - entry: pathToFileURL(fileId), - contentDir: contentPaths.contentDir, - }); - if (generatedInfo instanceof Error) return; + }); + } + const rawContents = await fs.promises.readFile(fileId, 'utf-8'); + const fileExt = extname(fileId); + if (!contentEntryExtToParser.has(fileExt)) { + throw new AstroError({ + ...AstroErrorData.UnknownContentCollectionError, + message: `No parser found for content entry ${JSON.stringify( + fileId + )}. Did you apply an integration for this file type?`, + }); + } + const contentEntryParser = contentEntryExtToParser.get(fileExt)!; + const info = await contentEntryParser.getEntryInfo({ + fileUrl: pathToFileURL(fileId), + contents: rawContents, + }); + const generatedInfo = getEntryInfo({ + entry: pathToFileURL(fileId), + contentDir: contentPaths.contentDir, + }); + if (generatedInfo instanceof NoCollectionError) throw generatedInfo; - const _internal = { filePath: fileId, rawData: info.rawData }; - // TODO: move slug calculation to the start of the build - // to generate a performant lookup map for `getEntryBySlug` - const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug }); + const _internal = { filePath: fileId, rawData: info.rawData }; + // TODO: move slug calculation to the start of the build + // to generate a performant lookup map for `getEntryBySlug` + const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug }); - const collectionConfig = contentConfig?.collections[generatedInfo.collection]; - let data = collectionConfig - ? await getEntryData( - { ...generatedInfo, _internal, unvalidatedData: info.data }, - collectionConfig - ) - : info.data; + const collectionConfig = contentConfig?.collections[generatedInfo.collection]; + let data = collectionConfig + ? await getEntryData( + { ...generatedInfo, _internal, unvalidatedData: info.data }, + collectionConfig + ) + : info.data; - await patchAssets(data, this.meta.watchMode, this.emitFile, settings); + await patchAssets(data, pluginContext.meta.watchMode, pluginContext.emitFile, settings); + const contentEntryModule: ContentEntryModule = { + ...generatedInfo, + _internal, + slug, + data, + body: info.body, + }; - const code = escapeViteEnvReferences(` -export const id = ${JSON.stringify(generatedInfo.id)}; -export const collection = ${JSON.stringify(generatedInfo.collection)}; + return contentEntryModule; + } + + const plugins: Plugin[] = [ + { + name: 'astro:content-imports', + async load(viteId) { + const { fileId } = getFileInfo(viteId, settings.config); + if (isContentFlagImport(viteId, contentEntryExts)) { + const { id, slug, collection, body, data, _internal } = await getEntryDataById( + fileId, + this + ); + + const code = escapeViteEnvReferences(` +export const id = ${JSON.stringify(id)}; +export const collection = ${JSON.stringify(collection)}; export const slug = ${JSON.stringify(slug)}; -export const body = ${JSON.stringify(info.body)}; +export const body = ${JSON.stringify(body)}; export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */}; export const _internal = { filePath: ${JSON.stringify(_internal.filePath)}, rawData: ${JSON.stringify(_internal.rawData)}, }; `); - return { code }; - } - }, - configureServer(viteServer) { - viteServer.watcher.on('all', async (event, entry) => { - if ( - ['add', 'unlink', 'change'].includes(event) && - getEntryType(entry, contentPaths, contentEntryExts) === 'config' - ) { - // Content modules depend on config, so we need to invalidate them. - for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) { - if (isContentFlagImport(modUrl, contentEntryExts)) { - const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl); - if (mod) { - viteServer.moduleGraph.invalidateModule(mod); + return { code }; + } + }, + configureServer(viteServer) { + viteServer.watcher.on('all', async (event, entry) => { + if ( + ['add', 'unlink', 'change'].includes(event) && + getEntryType(entry, contentPaths, contentEntryExts) === 'config' + ) { + // Content modules depend on config, so we need to invalidate them. + for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) { + if ( + isContentFlagImport(modUrl, contentEntryExts) || + // TODO: refine to content types with getModule + contentEntryExts.some((ext) => modUrl.endsWith(ext)) + ) { + const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl); + if (mod) { + viteServer.moduleGraph.invalidateModule(mod); + } } } } + }); + }, + async transform(code, id) { + if (isContentFlagImport(id, contentEntryExts)) { + // Escape before Rollup internal transform. + // Base on MUCH trial-and-error, inspired by MDX integration 2-step transform. + return { code: escapeViteEnvReferences(code) }; } - }); + }, }, - async transform(code, id) { - if (isContentFlagImport(id, contentEntryExts)) { - // Escape before Rollup internal transform. - // Base on MUCH trial-and-error, inspired by MDX integration 2-step transform. - return { code: escapeViteEnvReferences(code) }; - } - }, - }; + ]; + + if (settings.contentEntryTypes.some((t) => t.getModule)) { + plugins.push({ + name: 'astro:content-render-modules', + async load(viteId) { + if (!contentEntryExts.some((ext) => viteId.endsWith(ext))) return; + + const { fileId } = getFileInfo(viteId, settings.config); + for (const contentEntryType of settings.contentEntryTypes) { + if (contentEntryType.getModule) { + const entry = await getEntryDataById(fileId, this); + return contentEntryType.getModule({ entry }); + } + } + }, + }); + } + return plugins; } diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index acefcc787..57113cdc7 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -3,13 +3,8 @@ import Markdoc from '@markdoc/markdoc'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import type { InlineConfig, TransformResult } from 'vite'; -import { - getAstroConfigPath, - MarkdocError, - parseFrontmatter, - prependForwardSlash, -} from './utils.js'; +import type * as rollup from 'rollup'; +import { getAstroConfigPath, MarkdocError, parseFrontmatter } from './utils.js'; type IntegrationWithPrivateHooks = { name: string; @@ -41,7 +36,7 @@ export default function markdoc(markdocConfig: Config = {}): IntegrationWithPriv addContentEntryType({ extensions: ['.mdoc'], getEntryInfo, - getModule(entry: any /* TODO: typing */): Partial { + getModule({ entry }: any): Partial { validateRenderProperties(markdocConfig, config); const ast = Markdoc.parse(entry.body); const content = Markdoc.transform(ast, { @@ -49,13 +44,13 @@ export default function markdoc(markdocConfig: Config = {}): IntegrationWithPriv variables: { ...markdocConfig.variables, entry, - } + }, }); return { code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify( content - )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`; - } + )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`, + }; }, contentModuleTypes: await fs.promises.readFile( new URL('../template/content-module-types.d.ts', import.meta.url),