/** * Boot validation. Fatal problems stop the app from serving a menu it knows * is wrong; warnings are logged and the broken Page is skipped at render. */ import { access, readdir } from 'node:fs/promises'; import path from 'node:path'; import { ManifestError, readManifest, originalFileName, variantFileNames } from './manifest.ts'; import { StoredSet, sitePaths } from './sets.ts'; export interface Problem { level: 'fatal' | 'warning'; message: string; } export interface ValidateOptions { dataDir: string; sections: readonly string[]; } async function exists(target: string): Promise { try { await access(target); return true; } catch { return false; } } async function listDirectoryNames(dir: string): Promise { try { const entries = await readdir(dir, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } } export interface SiteContents { sets: StoredSet[]; problems: Problem[]; } /** Reads every set directory once; shared by validation and repair. */ export async function readSets(dataDir: string): Promise { const paths = sitePaths(dataDir); const problems: Problem[] = []; const sets: StoredSet[] = []; for (const state of ['draft', 'published'] as const) { const base = state === 'draft' ? paths.drafts : paths.published; for (const sectionDir of await listDirectoryNames(base)) { for (const key of await listDirectoryNames(path.join(base, sectionDir))) { const dir = path.join(base, sectionDir, key); let manifest; try { manifest = await readManifest(dir); } catch (error) { const detail = error instanceof ManifestError ? error.message : String(error); problems.push({ level: 'fatal', message: `${dir}: ${detail}` }); continue; } sets.push(new StoredSet(manifest, state, dir, state === 'published' ? key : null)); } } } return { sets, problems }; } export async function validateSite(options: ValidateOptions): Promise { const paths = sitePaths(options.dataDir); const { sets, problems } = await readSets(options.dataDir); for (const set of sets) { const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`; if (!options.sections.includes(set.manifest.section)) { problems.push({ level: 'fatal', message: `${where}: Section is not in the Site config` }); } if (set.manifest.section !== path.basename(path.dirname(set.dir))) { problems.push({ level: 'fatal', message: `${where}: manifest section does not match its directory` }); } if (set.state === 'published' && set.manifest.publishedAt === null) { problems.push({ level: 'fatal', message: `${where}: published set has no publishedAt` }); } if (set.state === 'draft' && set.manifest.publishedAt !== null) { problems.push({ level: 'fatal', message: `${where}: Draft has a publishedAt` }); } if (set.manifest.pages.length === 0) { problems.push({ level: set.state === 'published' ? 'fatal' : 'warning', message: `${where}: no Pages`, }); } const originals = path.join(paths.originals, set.manifest.section, set.manifest.id); for (const [index, page] of set.manifest.pages.entries()) { const names = variantFileNames(page); const missing: string[] = []; if (!(await exists(path.join(set.dir, names.webp)))) missing.push('viewing webp'); if (!(await exists(path.join(set.dir, names.jpeg)))) missing.push('viewing jpeg'); if (!(await exists(path.join(set.dir, names.thumb)))) missing.push('thumbnail'); if (!(await exists(path.join(originals, originalFileName(page))))) missing.push('original'); if (missing.length > 0) { problems.push({ level: set.state === 'published' ? 'fatal' : 'warning', message: `${where}: page ${index + 1} (${page.id}) is missing its ${missing.join(', ')}`, }); } } } // Two published sets in one Section may not share a URL segment. const slugs = new Map>(); for (const set of sets) { if (set.state !== 'published' || set.slug === null) continue; const key = set.manifest.section; const seen = slugs.get(key) ?? new Set(); if (seen.has(set.slug)) { problems.push({ level: 'fatal', message: `two published sets share the URL ${key}/${set.slug}` }); } seen.add(set.slug); slugs.set(key, seen); } return problems; } export function hasFatal(problems: readonly Problem[]): boolean { return problems.some((problem) => problem.level === 'fatal'); }