UNPKG

astro

Version:

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

495 lines (494 loc) • 17.8 kB
import * as path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import colors from "piccolore"; import { isRunnableDevEnvironment, normalizePath } from "vite"; import * as z from "zod/v4"; import { AstroError } from "../core/errors/errors.js"; import { AstroErrorData } from "../core/errors/index.js"; import { isRelativePath } from "../core/path.js"; import { COLLECTIONS_DIR, CONTENT_LAYER_TYPE, CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from "./consts.js"; import { getContentEntryIdAndSlug, getContentPaths, getDataEntryExts, getDataEntryId, getEntryCollectionName, getEntryConfigByExtMap, getEntrySlug, getEntryType, reloadContentConfigObserver } from "./utils.js"; import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js"; async function createContentTypesGenerator({ contentConfigObserver, fs, logger, settings, viteServer }) { const collectionEntryMap = {}; const contentPaths = getContentPaths( settings.config, fs, settings.config.legacy?.collectionsBackwardsCompat ); const contentEntryConfigByExt = getEntryConfigByExtMap(settings.contentEntryTypes); const contentEntryExts = [...contentEntryConfigByExt.keys()]; const dataEntryExts = getDataEntryExts(settings); let events = []; let debounceTimeout; const typeTemplateContent = await fs.promises.readFile(contentPaths.typesTemplate, "utf-8"); async function init() { events.push({ name: "add", entry: contentPaths.config.url }); await runEvents(); } async function handleEvent(event) { if (event.name === "addDir" || event.name === "unlinkDir") { const collection2 = normalizePath( path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry)) ); const collectionKey2 = JSON.stringify(collection2); const isCollectionEvent = collection2.split("/").length === 1; if (!isCollectionEvent) return { shouldGenerateTypes: false }; switch (event.name) { case "addDir": collectionEntryMap[collectionKey2] = { type: "unknown", entries: {} }; logger.debug("content", `${colors.cyan(collection2)} collection added`); break; case "unlinkDir": delete collectionEntryMap[collectionKey2]; break; } return { shouldGenerateTypes: true }; } const fileType = getEntryType( fileURLToPath(event.entry), contentPaths, contentEntryExts, dataEntryExts ); if (fileType === "ignored") { return { shouldGenerateTypes: false }; } if (fileType === "config") { await reloadContentConfigObserver({ fs, settings, environment: viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.astro] }); return { shouldGenerateTypes: true }; } const { entry } = event; const { contentDir } = contentPaths; const collection = getEntryCollectionName({ entry, contentDir }); if (collection === void 0) { logger.warn( "content", `${colors.bold( normalizePath( path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry)) ) )} must live in a ${colors.bold("content/...")} collection subdirectory.` ); return { shouldGenerateTypes: false }; } if (fileType === "data") { const id2 = getDataEntryId({ entry, contentDir, collection }); const collectionKey2 = JSON.stringify(collection); const entryKey2 = JSON.stringify(id2); switch (event.name) { case "add": if (!(collectionKey2 in collectionEntryMap)) { collectionEntryMap[collectionKey2] = { type: "data", entries: {} }; } const collectionInfo2 = collectionEntryMap[collectionKey2]; if (collectionInfo2.type === "content") { viteServer.environments.client.hot.send({ type: "error", err: new AstroError({ ...AstroErrorData.MixedContentDataCollectionError, message: AstroErrorData.MixedContentDataCollectionError.message(collectionKey2), location: { file: entry.pathname } }) }); return { shouldGenerateTypes: false }; } if (!(entryKey2 in collectionEntryMap[collectionKey2])) { collectionEntryMap[collectionKey2] = { type: "data", entries: { ...collectionInfo2.entries, [entryKey2]: {} } }; } return { shouldGenerateTypes: true }; case "unlink": if (collectionKey2 in collectionEntryMap && entryKey2 in collectionEntryMap[collectionKey2].entries) { delete collectionEntryMap[collectionKey2].entries[entryKey2]; } return { shouldGenerateTypes: true }; case "change": return { shouldGenerateTypes: false }; } } const contentEntryType = contentEntryConfigByExt.get(path.extname(event.entry.pathname)); if (!contentEntryType) return { shouldGenerateTypes: false }; const { id, slug: generatedSlug } = getContentEntryIdAndSlug({ entry, contentDir, collection }); const collectionKey = JSON.stringify(collection); if (!(collectionKey in collectionEntryMap)) { collectionEntryMap[collectionKey] = { type: "content", entries: {} }; } const collectionInfo = collectionEntryMap[collectionKey]; if (collectionInfo.type === "data") { viteServer.environments.client.hot.send({ type: "error", err: new AstroError({ ...AstroErrorData.MixedContentDataCollectionError, message: AstroErrorData.MixedContentDataCollectionError.message(collectionKey), location: { file: entry.pathname } }) }); return { shouldGenerateTypes: false }; } const entryKey = JSON.stringify(id); switch (event.name) { case "add": const addedSlug = await getEntrySlug({ generatedSlug, id, collection, fileUrl: event.entry, contentEntryType, fs }); if (!(entryKey in collectionEntryMap[collectionKey].entries)) { collectionEntryMap[collectionKey] = { type: "content", entries: { ...collectionInfo.entries, [entryKey]: { slug: addedSlug } } }; } return { shouldGenerateTypes: true }; case "unlink": if (collectionKey in collectionEntryMap && entryKey in collectionEntryMap[collectionKey].entries) { delete collectionEntryMap[collectionKey].entries[entryKey]; } return { shouldGenerateTypes: true }; case "change": const changedSlug = await getEntrySlug({ generatedSlug, id, collection, fileUrl: event.entry, contentEntryType, fs }); const entryMetadata = collectionInfo.entries[entryKey]; if (entryMetadata?.slug !== changedSlug) { collectionInfo.entries[entryKey].slug = changedSlug; return { shouldGenerateTypes: true }; } return { shouldGenerateTypes: false }; } } function queueEvent(rawEvent) { const event = { entry: pathToFileURL(rawEvent.entry), name: rawEvent.name }; if (contentPaths.config.url.pathname !== event.entry.pathname) { return; } events.push(event); debounceTimeout && clearTimeout(debounceTimeout); const runEventsSafe = async () => { try { await runEvents(); } catch { } }; debounceTimeout = setTimeout( runEventsSafe, 50 /* debounce to batch chokidar events */ ); } async function runEvents() { const eventResponses = []; for (const event of events) { const response = await handleEvent(event); eventResponses.push(response); } events = []; const observable = contentConfigObserver.get(); if (eventResponses.some((r) => r.shouldGenerateTypes)) { await writeContentFiles({ fs, collectionEntryMap, contentPaths, typeTemplateContent, contentConfig: observable.status === "loaded" ? observable.config : void 0, contentEntryTypes: settings.contentEntryTypes, viteServer, logger, settings }); if (!isRunnableDevEnvironment(viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) { return; } invalidateVirtualMod(viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]); } } return { init, queueEvent }; } function invalidateVirtualMod(environment) { const virtualMod = environment.moduleGraph.getModuleById("\0" + VIRTUAL_MODULE_ID); if (!virtualMod) return; environment.moduleGraph.invalidateModule(virtualMod); } function normalizeConfigPath(from, to) { const configPath = path.relative(from, to).replace(/\.ts$/, ".js"); const normalizedPath = configPath.replaceAll("\\", "/"); return `"${isRelativePath(configPath) ? "" : "./"}${normalizedPath}"`; } const createSchemaResultCache = /* @__PURE__ */ new Map(); async function getCreateSchemaResult(collection, collectionKey) { const cached = createSchemaResultCache.get(collectionKey); if (cached) { return cached; } if (collection?.type === CONTENT_LAYER_TYPE && typeof collection.loader === "object" && !collection.loader.schema && collection.loader.createSchema) { const result = await collection.loader.createSchema(); createSchemaResultCache.set(collectionKey, result); return result; } } async function getContentLayerSchema(collection, collectionKey) { if (collection?.type !== CONTENT_LAYER_TYPE || typeof collection.loader === "function") { return; } if (collection.loader.schema) { return collection.loader.schema; } const result = await getCreateSchemaResult(collection, collectionKey); return result?.schema; } async function typeForCollection(collection, collectionKey) { if (collection?.schema) { return { type: `InferEntrySchema<${collectionKey}>` }; } if (!collection?.type || typeof collection.loader === "function" || !collection.loader) { return { type: "any" }; } if (typeof collection.loader === "object" && collection.loader.schema) { return { type: `InferLoaderSchema<${collectionKey}>` }; } const result = await getCreateSchemaResult(collection, collectionKey); if (!result) { return { type: "any" }; } const base = `loaders/${collectionKey.slice(1, -1)}`; return { type: `import("./${base}.js").Entry`, injectedType: { filename: `${base}.ts`, content: result.types } }; } async function writeContentFiles({ fs, contentPaths, collectionEntryMap, typeTemplateContent, contentEntryTypes, contentConfig, viteServer, logger, settings }) { let dataTypesStr = ""; const collectionSchemasDir = new URL(COLLECTIONS_DIR, settings.dotAstroDir); fs.mkdirSync(collectionSchemasDir, { recursive: true }); for (const [collection, config] of Object.entries(contentConfig?.collections ?? {})) { collectionEntryMap[JSON.stringify(collection)] ??= { type: config.type ?? "unknown", entries: {} }; } let contentCollectionsMap = {}; for (const collectionKey of Object.keys(collectionEntryMap).sort()) { const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)]; const collection = collectionEntryMap[collectionKey]; if (collectionConfig?.type && collection.type !== "unknown" && collectionConfig.type !== CONTENT_LAYER_TYPE && collection.type !== collectionConfig.type) { viteServer.environments.client.hot.send({ type: "error", err: new AstroError({ ...AstroErrorData.ContentCollectionTypeMismatchError, message: AstroErrorData.ContentCollectionTypeMismatchError.message( collectionKey, collection.type, collectionConfig.type ), hint: collection.type === "data" ? "Try adding `type: 'data'` to your collection config." : void 0, location: { file: "" } }) }); return; } const { type: dataType, injectedType } = await typeForCollection( collectionConfig, collectionKey ); if (injectedType) { if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) { const url = new URL(injectedType.filename, settings.dotAstroDir); await fs.promises.mkdir(path.dirname(fileURLToPath(url)), { recursive: true }); await fs.promises.writeFile(url, injectedType.content, "utf-8"); } else { settings.injectedTypes.push(injectedType); } } dataTypesStr += `${collectionKey}: Record<string, { id: string; body?: string; collection: ${collectionKey}; data: ${dataType}; rendered?: RenderedContent; filePath?: string; }>; `; if (collectionConfig && (collectionConfig.schema || await getContentLayerSchema(collectionConfig, collectionKey))) { await generateJSONSchema(fs, collectionConfig, collectionKey, collectionSchemasDir, logger); contentCollectionsMap[collectionKey] = collection; } } if (settings.config.experimental.contentIntellisense) { let contentCollectionManifest = { collections: [], entries: {} }; Object.entries(contentCollectionsMap).forEach(([collectionKey, collection]) => { const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)]; const key = JSON.parse(collectionKey); contentCollectionManifest.collections.push({ hasSchema: Boolean( // Is there a user provided schema or collectionConfig?.schema || // Is it a loader object and typeof collectionConfig?.loader === "object" && // Is it a loader static schema or (collectionConfig.loader.schema || // is it a loader dynamic schema createSchemaResultCache.has(collectionKey)) ), name: key }); Object.keys(collection.entries).forEach((entryKey) => { const entryPath = new URL( JSON.parse(entryKey), contentPaths.contentDir + `${key}/` ).toString(); contentCollectionManifest.entries[entryPath.toLowerCase()] = key; }); }); await fs.promises.writeFile( new URL("./collections.json", collectionSchemasDir), JSON.stringify(contentCollectionManifest, null, 2) ); } const configPathRelativeToCacheDir = normalizeConfigPath( settings.dotAstroDir.pathname, contentPaths.config.url.pathname ); const liveConfigPathRelativeToCacheDir = contentPaths.liveConfig?.exists ? normalizeConfigPath(settings.dotAstroDir.pathname, contentPaths.liveConfig.url.pathname) : void 0; for (const contentEntryType of contentEntryTypes) { if (contentEntryType.contentModuleTypes) { typeTemplateContent = contentEntryType.contentModuleTypes + "\n" + typeTemplateContent; } } typeTemplateContent = typeTemplateContent.replace("// @@DATA_ENTRY_MAP@@", dataTypesStr).replace( "'@@CONTENT_CONFIG_TYPE@@'", contentConfig ? `typeof import(${configPathRelativeToCacheDir})` : "never" ).replace( "'@@LIVE_CONTENT_CONFIG_TYPE@@'", liveConfigPathRelativeToCacheDir ? `typeof import(${liveConfigPathRelativeToCacheDir})` : "never" ); if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) { await fs.promises.writeFile( new URL(CONTENT_TYPES_FILE, settings.dotAstroDir), typeTemplateContent, "utf-8" ); } else { settings.injectedTypes.push({ filename: CONTENT_TYPES_FILE, content: typeTemplateContent }); } } async function generateJSONSchema(fsMod, collectionConfig, collectionKey, collectionSchemasDir, logger) { let zodSchemaForJson = typeof collectionConfig.schema === "function" ? collectionConfig.schema({ image: () => z.string() }) : collectionConfig.schema; if (!zodSchemaForJson && collectionConfig.type === CONTENT_LAYER_TYPE) { zodSchemaForJson = await getContentLayerSchema(collectionConfig, collectionKey); } if (collectionConfig.type === CONTENT_LAYER_TYPE && collectionConfig.loader.name === "file-loader") { const itemSchema = zodSchemaForJson; zodSchemaForJson = z.union([ z.array(itemSchema), z.object({ $schema: z.string().optional() }).catchall(itemSchema) ]); } if (zodSchemaForJson instanceof z.ZodObject) { const existingMeta = z.globalRegistry.get(zodSchemaForJson); zodSchemaForJson = zodSchemaForJson.extend({ $schema: z.string().optional() }); if (existingMeta) { z.globalRegistry.add(zodSchemaForJson, existingMeta); } } try { const schema = z.toJSONSchema(zodSchemaForJson, { unrepresentable: "any", override: (ctx) => { const def = ctx.zodSchema._zod.def; if (def.type === "date") { ctx.jsonSchema.type = "string"; ctx.jsonSchema.format = "date-time"; } }, // Collection schemas are used for parsing collection input, so we need to tell Zod to use the // input shape when generating a JSON schema. io: "input" }); const schemaStr = JSON.stringify(schema, null, 2); const schemaJsonPath = new URL( `./${collectionKey.replace(/"/g, "")}.schema.json`, collectionSchemasDir ); await fsMod.promises.writeFile(schemaJsonPath, schemaStr); } catch (err) { logger.warn( "content", `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}` ); } } export { createContentTypesGenerator };