verify reports integrity problems and repair rebuilds derivatives from the Originals, which is what makes the file layout trustworthy without a database. The image runs TypeScript on Node 24 with no build step, writes only under MENU_ROOT, and the provision script writes the untracked site.env using the image's own hasher so the server needs no Node.
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
try {
|
|
await access(target);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function listDirectoryNames(dir: string): Promise<string[]> {
|
|
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<SiteContents> {
|
|
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<Problem[]> {
|
|
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<string, Set<string>>();
|
|
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<string>();
|
|
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');
|
|
}
|