astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
529 lines (528 loc) • 18.3 kB
JavaScript
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import glob from "fast-glob";
import { bold, cyan } from "kleur/colors";
import { normalizePath } from "vite";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
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";
async function createContentTypesGenerator({
contentConfigObserver,
fs,
logger,
settings,
viteServer
}) {
const collectionEntryMap = {};
const contentPaths = getContentPaths(settings.config, fs);
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 });
if (settings.config.legacy.collections) {
if (!fs.existsSync(contentPaths.contentDir)) {
return { typesGenerated: false, reason: "no-content-dir" };
}
const globResult = await glob("**", {
cwd: fileURLToPath(contentPaths.contentDir),
fs: {
readdir: fs.readdir.bind(fs),
readdirSync: fs.readdirSync.bind(fs)
},
onlyFiles: false,
objectMode: true
});
for (const entry of globResult) {
const fullPath = path.join(fileURLToPath(contentPaths.contentDir), entry.path);
const entryURL = pathToFileURL(fullPath);
if (entryURL.href.startsWith(contentPaths.config.url.href)) continue;
if (entry.dirent.isFile()) {
events.push({ name: "add", entry: entryURL });
} else if (entry.dirent.isDirectory()) {
events.push({ name: "addDir", entry: entryURL });
}
}
}
await runEvents();
return { typesGenerated: true };
}
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", `${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, viteServer });
return { shouldGenerateTypes: true };
}
const { entry } = event;
const { contentDir } = contentPaths;
const collection = getEntryCollectionName({ entry, contentDir });
if (collection === void 0) {
logger.warn(
"content",
`${bold(
normalizePath(
path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry))
)
)} must live in a ${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.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.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 (!event.entry.pathname.startsWith(contentPaths.contentDir.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
});
invalidateVirtualMod(viteServer);
}
}
return { init, queueEvent };
}
function invalidateVirtualMod(viteServer) {
const virtualMod = viteServer.moduleGraph.getModuleById("\0" + VIRTUAL_MODULE_ID);
if (!virtualMod) return;
viteServer.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 schemaCache = /* @__PURE__ */ new Map();
async function getContentLayerSchema(collection, collectionKey) {
const cached = schemaCache.get(collectionKey);
if (cached) {
return cached;
}
if (collection?.type === CONTENT_LAYER_TYPE && typeof collection.loader === "object" && collection.loader.schema) {
let schema = collection.loader.schema;
if (typeof schema === "function") {
schema = await schema();
}
if (schema) {
schemaCache.set(collectionKey, await schema);
return schema;
}
}
}
async function typeForCollection(collection, collectionKey) {
if (collection?.schema) {
return `InferEntrySchema<${collectionKey}>`;
}
if (collection?.type === CONTENT_LAYER_TYPE) {
const schema = await getContentLayerSchema(collection, collectionKey);
if (schema) {
try {
const zodToTs = await import("zod-to-ts");
const ast = zodToTs.zodToTs(schema);
return zodToTs.printNode(ast.node);
} catch (err) {
if (err.message.includes("Cannot find package 'typescript'")) {
return "any";
}
throw err;
}
}
}
return "any";
}
async function writeContentFiles({
fs,
contentPaths,
collectionEntryMap,
typeTemplateContent,
contentEntryTypes,
contentConfig,
viteServer,
logger,
settings
}) {
let contentTypesStr = "";
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,
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.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 resolvedType = collection.type === "unknown" ? (
// Add empty / unknown collections to the data type map by default
// This ensures `getCollection('empty-collection')` doesn't raise a type error
collectionConfig?.type ?? "data"
) : collection.type;
const collectionEntryKeys = Object.keys(collection.entries).sort();
const dataType = await typeForCollection(collectionConfig, collectionKey);
switch (resolvedType) {
case "content":
if (collectionEntryKeys.length === 0) {
contentTypesStr += `${collectionKey}: Record<string, {
id: string;
slug: string;
body: string;
collection: ${collectionKey};
data: ${dataType};
render(): Render[".md"];
}>;
`;
break;
}
contentTypesStr += `${collectionKey}: {
`;
for (const entryKey of collectionEntryKeys) {
const entryMetadata = collection.entries[entryKey];
const renderType = `{ render(): Render[${JSON.stringify(
path.extname(JSON.parse(entryKey))
)}] }`;
const slugType = JSON.stringify(entryMetadata.slug);
contentTypesStr += `${entryKey}: {
id: ${entryKey};
slug: ${slugType};
body: string;
collection: ${collectionKey};
data: ${dataType}
} & ${renderType};
`;
}
contentTypesStr += `};
`;
break;
case CONTENT_LAYER_TYPE:
const legacyTypes = collectionConfig?._legacy ? 'render(): Render[".md"];\n slug: string;\n body: string;\n' : "body?: string;\n";
dataTypesStr += `${collectionKey}: Record<string, {
id: string;
${legacyTypes} collection: ${collectionKey};
data: ${dataType};
rendered?: RenderedContent;
filePath?: string;
}>;
`;
break;
case "data":
if (collectionEntryKeys.length === 0) {
dataTypesStr += `${collectionKey}: Record<string, {
id: string;
collection: ${collectionKey};
data: ${dataType};
}>;
`;
} else {
dataTypesStr += `${collectionKey}: {
`;
for (const entryKey of collectionEntryKeys) {
dataTypesStr += `${entryKey}: {
id: ${entryKey};
collection: ${collectionKey};
data: ${dataType}
};
`;
}
dataTypesStr += `};
`;
}
break;
}
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(collectionConfig?.schema || schemaCache.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
);
for (const contentEntryType of contentEntryTypes) {
if (contentEntryType.contentModuleTypes) {
typeTemplateContent = contentEntryType.contentModuleTypes + "\n" + typeTemplateContent;
}
}
typeTemplateContent = typeTemplateContent.replace("// @@CONTENT_ENTRY_MAP@@", contentTypesStr);
typeTemplateContent = typeTemplateContent.replace("// @@DATA_ENTRY_MAP@@", dataTypesStr);
typeTemplateContent = typeTemplateContent.replace(
"'@@CONTENT_CONFIG_TYPE@@'",
contentConfig ? `typeof import(${configPathRelativeToCacheDir})` : "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 (zodSchemaForJson instanceof z.ZodObject) {
zodSchemaForJson = zodSchemaForJson.extend({
$schema: z.string().optional()
});
}
try {
await fsMod.promises.writeFile(
new URL(`./${collectionKey.replace(/"/g, "")}.schema.json`, collectionSchemasDir),
JSON.stringify(
zodToJsonSchema(zodSchemaForJson, {
name: collectionKey.replace(/"/g, ""),
markdownDescription: true,
errorMessages: true,
// Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110
dateStrategy: ["format:date-time", "format:date", "integer"]
}),
null,
2
)
);
} 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
};