Files
menu/src/storage/validate.ts
T
tanshu bb9ddb437f Ship a repair command and a container
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.
2026-09-22 21:24:47 +05:30

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');
}