import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import type { CollectionConfig, CollectionEntry, ContentSchema, LoadedFile, ResolvedConfig, } from "../contracts/index.ts"; import { Service } from "../decorators/Service.ts"; /** Validates the frontmatter of an entry against the schema of a collection. */ export function validateFrontmatter( schema: ContentSchema | undefined, frontmatter: Record, ): string[] { if (!schema) return []; const errors: string[] = []; for (const field of schema.required ?? []) { const value = frontmatter[field]; if (value === undefined || value === "") { errors.push(`${field} is required`); } } for (const [field, allowed] of Object.entries(schema.enum ?? {})) { const value = frontmatter[field]; if (value === undefined) continue; if (!allowed.includes(String(value))) { errors.push(`${field} must be one of ${allowed.join(", ")}`); } } for (const [field, length] of Object.entries(schema.maxLength ?? {})) { const value = frontmatter[field]; if (value === undefined) continue; if (String(value).length > length) { errors.push(`${field} must be at most ${length} characters`); } } return errors; } /** Registry of the content collections of a project. */ export class ContentRegistry { private readonly collections = new Map(); constructor(private readonly config: ResolvedConfig) {} /** Loads every collection of the project. */ async load(): Promise { for (const collection of this.config.collections ?? []) { this.collections.set( collection.name, await this.loadCollection(collection), ); } } /** Entries of a collection. */ entries = Record>( name: string, ): CollectionEntry[] { return (this.collections.get(name) ?? []) as CollectionEntry[]; } /** A single entry of a collection. */ get = Record>( name: string, id: string, ): CollectionEntry | undefined { return this.entries(name).find((entry) => entry.id === id); } /** Names of the loaded collections. */ names(): string[] { return [...this.collections.keys()]; } private async loadCollection( collection: CollectionConfig, ): Promise { const dir = resolve(this.config.root, collection.dir); if (!existsSync(dir)) return []; const loader = typeof collection.loader === "string" ? this.config.loaders[collection.loader] : collection.loader; if (!loader) { throw new Error( `Unknown loader "${collection.loader}" of the collection "${collection.name}"`, ); } const files = await listCollectionFiles(dir, loader.extensions); const entries: CollectionEntry[] = []; for (const file of files.sort()) { const path = join(dir, file); const source = await readFile(path, "utf8"); const loaded: LoadedFile = await loader.load({ path, relativePath: relative(this.config.root, path).replace(/\\/g, "/"), source, }); const errors = validateFrontmatter(collection.schema, loaded.data); if (errors.length > 0) { throw new Error( `Invalid frontmatter in ${relative(this.config.root, path)}:\n${errors .map((error) => ` - ${error}`) .join("\n")}`, ); } const id = file.replace(/\.[^.]+$/, "").replace(/\\/g, "/"); entries.push({ id, path: relative(this.config.root, path).replace(/\\/g, "/"), frontmatter: loaded.data, body: loaded.content ?? "", content: source, }); } return entries; } } async function listCollectionFiles(dir: string, extensions: string[], prefix = ""): Promise { const files: string[] = []; for (const entry of await readdir(dir, { withFileTypes: true })) { const relativePath = join(prefix, entry.name); if (entry.isDirectory()) files.push(...(await listCollectionFiles(join(dir, entry.name), extensions, relativePath))); else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(relativePath); } return files.sort(); } /** Service exposing the content collections to the controllers. */ @Service() export class ContentService { constructor(private readonly registry: ContentRegistry) {} /** Entries of a collection. */ list = Record>( name: string, ): CollectionEntry[] { return this.registry.entries(name); } /** A single entry of a collection. */ get = Record>( name: string, id: string, ): CollectionEntry | undefined { return this.registry.get(name, id); } }