astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
448 lines (447 loc) • 18.6 kB
JavaScript
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 { getContentConfigLoadPromise } from "./config-prewarm.js";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../core/constants.js";
export 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 collection = normalizePath(path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry)));
const collectionKey = JSON.stringify(collection);
// If directory is multiple levels deep, it is not a collection. Ignore event.
const isCollectionEvent = collection.split("/").length === 1;
if (!isCollectionEvent) return { shouldGenerateTypes: false };
switch (event.name) {
case "addDir":
collectionEntryMap[collectionKey] = {
type: "unknown",
entries: {}
};
logger.debug("content", `${colors.cyan(collection)} collection added`);
break;
case "unlinkDir":
delete collectionEntryMap[collectionKey];
break;
}
return { shouldGenerateTypes: true };
}
const fileType = getEntryType(fileURLToPath(event.entry), contentPaths, contentEntryExts, dataEntryExts);
if (fileType === "ignored") {
return { shouldGenerateTypes: false };
}
if (fileType === "config") {
// Dev server creation may already be loading this config. Wait for that
// exact load when present; otherwise this path owns the reload. Do not
// infer ownership from the global observer status because `astro sync`
// can retain a settled status from an earlier invocation in the process.
const prewarm = getContentConfigLoadPromise();
if (prewarm) {
await prewarm;
} else {
await reloadContentConfigObserver({
fs,
settings,
environment: viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.astro],
logger
});
}
return { shouldGenerateTypes: true };
}
const { entry } = event;
const { contentDir } = contentPaths;
const collection = getEntryCollectionName({
entry,
contentDir
});
if (collection === undefined) {
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 id = getDataEntryId({
entry,
contentDir,
collection
});
const collectionKey = JSON.stringify(collection);
const entryKey = JSON.stringify(id);
switch (event.name) {
case "add":
if (!(collectionKey in collectionEntryMap)) {
collectionEntryMap[collectionKey] = {
type: "data",
entries: {}
};
}
const collectionInfo = collectionEntryMap[collectionKey];
if (collectionInfo.type === "content") {
viteServer.environments.client.hot.send({
type: "error",
err: new AstroError({
...AstroErrorData.MixedContentDataCollectionError,
message: AstroErrorData.MixedContentDataCollectionError.message(collectionKey),
location: { file: entry.pathname }
})
});
return { shouldGenerateTypes: false };
}
if (!(entryKey in collectionEntryMap[collectionKey])) {
collectionEntryMap[collectionKey] = {
type: "data",
entries: {
...collectionInfo.entries,
[entryKey]: {}
}
};
}
return { shouldGenerateTypes: true };
case "unlink":
if (collectionKey in collectionEntryMap && entryKey in collectionEntryMap[collectionKey].entries) {
delete collectionEntryMap[collectionKey].entries[entryKey];
}
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":
// User may modify `slug` in their frontmatter.
// Only regen types if this change is detected.
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 : undefined,
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
};
}
// The virtual module contains a lookup map from slugs to content imports.
// Invalidate whenever content types change.
function invalidateVirtualMod(environment) {
const virtualMod = environment.moduleGraph.getModuleById("\0" + VIRTUAL_MODULE_ID);
if (!virtualMod) return;
environment.moduleGraph.invalidateModule(virtualMod);
}
/**
* Takes the source (`from`) and destination (`to`) of a config path and
* returns a normalized relative version:
* - If is not relative, it adds `./` to the beginning.
* - If it ends with `.ts`, it replaces it with `.js`.
* - It adds `""` around the string.
* @param from Config path source.
* @param to Config path destination.
* @returns Normalized config path.
*/
function normalizeConfigPath(from, to) {
const configPath = path.relative(from, to).replace(/\.ts$/, ".js");
// on windows `path.relative` will use backslashes, these must be replaced with forward slashes
const normalizedPath = configPath.replaceAll("\\", "/");
return `"${isRelativePath(configPath) ? "" : "./"}${normalizedPath}"`;
}
const createSchemaResultCache = 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." : undefined,
location: { file: "" }
})
});
return;
}
const { type: dataType, injectedType } = await typeForCollection(collectionConfig, collectionKey);
if (injectedType) {
if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) {
// If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
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, {\n id: string;\n body?: string;\n collection: ${collectionKey};\n data: ${dataType};\n rendered?: RenderedContent;\n filePath?: string;\n digest?: string | number;\n}>;\n`;
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 || typeof collectionConfig?.loader === "object" && (collectionConfig.loader.schema || createSchemaResultCache.has(collectionKey))
),
name: key
});
Object.keys(collection.entries).forEach((entryKey) => {
const entryPath = new URL(JSON.parse(entryKey), contentPaths.contentDir + `${key}/`).toString();
// Save entry path in lowercase to avoid case sensitivity issues between Windows and Unix
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) : undefined;
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 it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
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);
}
// The `file()` loader uses a schema which applies to every item in the file rather than a schema
// for the whole file. We special case this to provide the correct JSON schema to users.
// TODO: it would be nice if loaders could indicate this behavior so it wasn't unique to the built-in loader.
if (collectionConfig.type === CONTENT_LAYER_TYPE && collectionConfig.loader.name === "file-loader") {
// `file()` supports both top-level arrays and record objects. Generate an anyOf schema
// so VS Code validates correctly regardless of which shape the source file uses.
// `$schema` is injected into the object branch only — top-level array JSON files cannot
// reference a schema property per the JSON Schema spec.
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) {
// This should error gracefully and not crash the dev server
logger.warn("content", `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`);
}
}