UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

364 lines (363 loc) • 12.5 kB
import { extname } from "node:path"; import { pathToFileURL } from "node:url"; import * as devalue from "devalue"; import { getImageAssetCode, IMAGE_ASSET_IMPORT } from "../assets/utils/image-asset-code.js"; import { createContentDataIncrementalMetadata } from "../core/build/incremental-metadata.js"; import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js"; import { AstroError } from "../core/errors/errors.js"; import { AstroErrorData } from "../core/errors/index.js"; import { CONTENT_FLAG, DATA_FLAG } from "./consts.js"; import { getContentEntryExts, getContentEntryIdAndSlug, getContentPaths, getDataEntryExts, getDataEntryId, getEntryCollectionName, getEntryConfigByExtMap, getEntryData, getEntryType, getSymlinkedContentCollections, globalContentConfigObserver, hasContentFlag, parseEntrySlug, reloadContentConfigObserver, reverseSymlink } from "./utils.js"; function getContentRendererByViteId(viteId, settings) { let ext = viteId.split(".").pop(); if (!ext) return undefined; for (const contentEntryType of settings.contentEntryTypes) { if (Boolean(contentEntryType.getRenderModule) && contentEntryType.extensions.includes("." + ext)) { return contentEntryType.getRenderModule; } } return undefined; } const CHOKIDAR_MODIFIED_EVENTS = [ "add", "unlink", "change" ]; /** * If collection entries change, import modules need to be invalidated. * Reasons why: * - 'config' - content imports depend on the config file for parsing schemas * - 'data' | 'content' - the config may depend on collection entries via `reference()` */ const COLLECTION_TYPES_TO_INVALIDATE_ON = [ "data", "content", "config" ]; export function astroContentImportPlugin({ fs, settings, logger }) { const contentPaths = getContentPaths(settings.config, fs, settings.config.legacy?.collectionsBackwardsCompat); const contentEntryExts = getContentEntryExts(settings); const dataEntryExts = getDataEntryExts(settings); const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes); const dataEntryConfigByExt = getEntryConfigByExtMap(settings.dataEntryTypes); const { contentDir } = contentPaths; let shouldEmitFile = false; let symlinks; const plugins = [{ name: "astro:content-imports", config(_config, env) { shouldEmitFile = env.command === "build"; }, async buildStart() { // Get symlinks once at build start symlinks = await getSymlinkedContentCollections({ contentDir, logger, fs }); }, transform: { filter: { id: new RegExp(`(?:\\?|&)(?:${DATA_FLAG}|${CONTENT_FLAG})(?:&|=|$)`) }, async handler(_, viteId) { if (hasContentFlag(viteId, DATA_FLAG)) { // By default, Vite will resolve symlinks to their targets. We need to reverse this for // content entries, so we can get the path relative to the content directory. const fileId = reverseSymlink({ entry: viteId.split("?")[0] ?? viteId, contentDir, symlinks }); // Data collections don't need to rely on the module cache. // This cache only exists for the `render()` function specific to content. const { id, data, collection, _internal } = await getDataEntryModule({ fileId, entryConfigByExt: dataEntryConfigByExt, contentDir, config: settings.config, fs, pluginContext: this, shouldEmitFile }); const entryData = stringifyEntryData(data, settings.buildOutput === "server"); const code = `${entryData.imports} export const id = ${JSON.stringify(id)}; export const collection = ${JSON.stringify(collection)}; export const data = ${entryData.code}; export const _internal = { type: 'data', filePath: ${JSON.stringify(_internal.filePath)}, rawData: ${JSON.stringify(_internal.rawData)}, }; `; return { code, map: { mappings: "" }, meta: createContentDataIncrementalMetadata() }; } else if (hasContentFlag(viteId, CONTENT_FLAG)) { const fileId = reverseSymlink({ entry: viteId.split("?")[0], contentDir, symlinks }); const { id, slug, collection, body, data, _internal } = await getContentEntryModule({ fileId, entryConfigByExt: contentEntryConfigByExt, contentDir, config: settings.config, fs, pluginContext: this, shouldEmitFile }); const entryData = stringifyEntryData(data, settings.buildOutput === "server"); const code = `${entryData.imports} export const id = ${JSON.stringify(id)}; export const collection = ${JSON.stringify(collection)}; export const slug = ${JSON.stringify(slug)}; export const body = ${JSON.stringify(body)}; export const data = ${entryData.code}; export const _internal = { type: 'content', filePath: ${JSON.stringify(_internal.filePath)}, rawData: ${JSON.stringify(_internal.rawData)}, };`; return { code, map: { mappings: "" }, meta: createContentDataIncrementalMetadata() }; } } }, configureServer(viteServer) { viteServer.watcher.on("all", async (event, entry) => { if (CHOKIDAR_MODIFIED_EVENTS.includes(event)) { const environment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]; const timestamp = Date.now(); const entryType = getEntryType(entry, contentPaths, contentEntryExts, dataEntryExts); if (!COLLECTION_TYPES_TO_INVALIDATE_ON.includes(entryType)) return; // The content config could depend on collection entries via `reference()`. // Reload the config in case of changes. // Changes to the config file itself are handled in types-generator.ts, so we skip them here if (entryType === "content" || entryType === "data") { await reloadContentConfigObserver({ fs, settings, environment: viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.astro], logger }); } // Invalidate all content imports and `render()` modules. // TODO: trace `reference()` calls for fine-grained invalidation. for (const modUrl of environment.moduleGraph.urlToModuleMap.keys()) { if (hasContentFlag(modUrl, CONTENT_FLAG) || hasContentFlag(modUrl, DATA_FLAG) || Boolean(getContentRendererByViteId(modUrl, settings))) { try { const mod = await environment.moduleGraph.getModuleByUrl(modUrl); if (mod) { // Pass `true` to mark this as HMR invalidation so Vite drops cached SSR results. environment.moduleGraph.invalidateModule(mod, undefined, timestamp, true); } } catch (e) { // The server may be closed due to a restart caused by this file change if (e.code === "ERR_CLOSED_SERVER") break; throw e; } } } } }); } }]; if (settings.contentEntryTypes.some((t) => t.getRenderModule)) { plugins.push({ name: "astro:content-render-imports", transform: { filter: { id: { include: settings.contentEntryTypes.filter((t) => t.getRenderModule).map((t) => new RegExp(`\\.(${t.extensions.map((e) => e.slice(1)).join("|")})$`)) } }, async handler(contents, viteId) { const contentRenderer = getContentRendererByViteId(viteId, settings); if (!contentRenderer) return; const fileId = viteId.split("?")[0]; return contentRenderer.bind(this)({ viteId, contents, fileUrl: pathToFileURL(fileId) }); } } }); } return plugins; } async function getContentEntryModule(params) { const { fileId, contentDir, pluginContext } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); const { rawData, data: unvalidatedData, body, slug: frontmatterSlug } = await entryConfig.getEntryInfo({ fileUrl: pathToFileURL(fileId), contents: rawContents }); const _internal = { filePath: fileId, rawData }; const { id, slug: generatedSlug } = getContentEntryIdAndSlug({ entry, contentDir, collection }); const slug = parseEntrySlug({ id, collection, generatedSlug, frontmatterSlug }); const data = collectionConfig ? await getEntryData({ id, collection, _internal, unvalidatedData }, collectionConfig, params.shouldEmitFile, pluginContext) : unvalidatedData; const contentEntryModule = { id, slug, collection, data, body, _internal }; return contentEntryModule; } async function getDataEntryModule(params) { const { fileId, contentDir, pluginContext } = params; const { collectionConfig, entryConfig, entry, rawContents, collection } = await getEntryModuleBaseInfo(params); const { rawData = "", data: unvalidatedData } = await entryConfig.getEntryInfo({ fileUrl: pathToFileURL(fileId), contents: rawContents }); const _internal = { filePath: fileId, rawData }; const id = getDataEntryId({ entry, contentDir, collection }); const data = collectionConfig ? await getEntryData({ id, collection, _internal, unvalidatedData }, collectionConfig, params.shouldEmitFile, pluginContext) : unvalidatedData; const dataEntryModule = { id, collection, data, _internal }; return dataEntryModule; } // Shared logic for `getContentEntryModule` and `getDataEntryModule` // Extracting to a helper was easier that conditionals and generics :) async function getEntryModuleBaseInfo({ fileId, entryConfigByExt, contentDir, fs }) { const contentConfig = await getContentConfigFromGlobal(); let rawContents; try { rawContents = await fs.promises.readFile(fileId, "utf-8"); } catch (e) { throw new AstroError({ ...AstroErrorData.UnknownContentCollectionError, message: `Unexpected error reading entry ${JSON.stringify(fileId)}.`, stack: e instanceof Error ? e.stack : undefined }); } const fileExt = extname(fileId); const entryConfig = entryConfigByExt.get(fileExt); if (!entryConfig) { throw new AstroError({ ...AstroErrorData.UnknownContentCollectionError, message: `No parser found for data entry ${JSON.stringify(fileId)}. Did you apply an integration for this file type?` }); } const entry = pathToFileURL(fileId); const collection = getEntryCollectionName({ entry, contentDir }); if (collection === undefined) throw new AstroError(AstroErrorData.UnknownContentCollectionError); const collectionConfig = contentConfig?.collections[collection]; return { collectionConfig, entry, entryConfig, collection, rawContents }; } async function getContentConfigFromGlobal() { const observable = globalContentConfigObserver.get(); // Content config should be loaded before being accessed from Vite plugins 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 = 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(); } if (ctx.status === "error") { resolve(undefined); unsubscribe(); } }); }); } return contentConfig; } /** Stringify entry `data` at build time to be used as a Vite module */ function stringifyEntryData(data, isSSR) { let hasImageAsset = false; try { const code = devalue.uneval(data, (value, js) => { // Add support for URL objects if (value instanceof URL) { return js`new URL(${value.href})`; } // For Astro assets, track references to their `src` if (typeof value === "object" && "ASTRO_ASSET" in value) { // `createImageAsset` defines a non-enumerable `fsPath`, as for imported images. const { ASTRO_ASSET, fsPath: _fsPath, ...asset } = value; hasImageAsset = true; return getImageAssetCode(js, asset, ASTRO_ASSET, !isSSR); } }); return { imports: hasImageAsset ? IMAGE_ASSET_IMPORT : "", code }; } catch (e) { if (e instanceof Error) { throw new AstroError({ ...AstroErrorData.UnsupportedConfigTransformError, message: AstroErrorData.UnsupportedConfigTransformError.message(e.message), stack: e.stack }); } else { throw new AstroError({ name: "PluginContentImportsError", message: "Unexpected error processing content collection data." }); } } }