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
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 { 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
};