163 lines
4.7 KiB
TypeScript
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);
|
|
}
|
|
}
|