muddle/core/content.ts
2026-10-23 08:53:07 -04:00

163 lines
4.7 KiB
TypeScript

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, unknown>,
): 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<string, CollectionEntry[]>();
constructor(private readonly config: ResolvedConfig) {}
/** Loads every collection of the project. */
async load(): Promise<void> {
for (const collection of this.config.collections ?? []) {
this.collections.set(
collection.name,
await this.loadCollection(collection),
);
}
}
/** Entries of a collection. */
entries<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
): CollectionEntry<T>[] {
return (this.collections.get(name) ?? []) as CollectionEntry<T>[];
}
/** A single entry of a collection. */
get<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
id: string,
): CollectionEntry<T> | undefined {
return this.entries<T>(name).find((entry) => entry.id === id);
}
/** Names of the loaded collections. */
names(): string[] {
return [...this.collections.keys()];
}
private async loadCollection(
collection: CollectionConfig,
): Promise<CollectionEntry[]> {
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<string[]> {
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<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
): CollectionEntry<T>[] {
return this.registry.entries<T>(name);
}
/** A single entry of a collection. */
get<T extends Record<string, unknown> = Record<string, unknown>>(
name: string,
id: string,
): CollectionEntry<T> | undefined {
return this.registry.get<T>(name, id);
}
}