196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
/**
|
||
* Validate canon data files and simulate the ServiceNode output
|
||
* that loadCanonData() produces.
|
||
* Run with: npx tsx scripts/validate-canons.ts
|
||
*/
|
||
|
||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
|
||
const __dirname = new URL('.', import.meta.url).pathname;
|
||
const CANONS_DIR = join(__dirname, '..', 'static', 'data', 'shared', 'services', 'canons');
|
||
|
||
interface CanonSubCanon {
|
||
irmos: string;
|
||
troparia: string[];
|
||
theotokion: string;
|
||
}
|
||
|
||
interface CanonOde {
|
||
ode: number;
|
||
canons: CanonSubCanon[];
|
||
}
|
||
|
||
interface CanonData {
|
||
tone: number;
|
||
odes: CanonOde[];
|
||
}
|
||
|
||
interface ServiceNode {
|
||
type: 'HEADER' | 'TEXT';
|
||
value: string;
|
||
}
|
||
|
||
const ODE_SLAV = ['', 'а', 'в', 'г', 'д', 'є', 'ѕ', 'з', 'и', 'ѳ'];
|
||
|
||
function simulateLoadCanonData(data: CanonData): ServiceNode[] {
|
||
const nodes: ServiceNode[] = [];
|
||
for (const ode of data.odes) {
|
||
const slavNum = ODE_SLAV[ode.ode] ?? String(ode.ode);
|
||
nodes.push({ type: 'HEADER', value: `Пѣснь ${slavNum}҃.` });
|
||
|
||
for (const canon of ode.canons) {
|
||
if (canon.irmos) {
|
||
nodes.push({ type: 'HEADER', value: `Ірмосъ, гла́съ ${data.tone}:` });
|
||
nodes.push({ type: 'TEXT', value: canon.irmos });
|
||
}
|
||
for (const trop of canon.troparia) {
|
||
nodes.push({ type: 'TEXT', value: trop });
|
||
}
|
||
if (canon.theotokion) {
|
||
nodes.push({ type: 'HEADER', value: 'Бг҃ородиченъ:' });
|
||
nodes.push({ type: 'TEXT', value: canon.theotokion });
|
||
}
|
||
}
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
let errors = 0;
|
||
let warnings = 0;
|
||
|
||
function err(msg: string) { console.error(` ERROR: ${msg}`); errors++; }
|
||
function warn(msg: string) { console.warn(` WARN: ${msg}`); warnings++; }
|
||
|
||
for (const entry of readdirSync(CANONS_DIR).sort()) {
|
||
// Skip non-Sunday-canon subdirectories (great-canon parts, paraclete weekday canons)
|
||
if (entry === 'great-canon' || entry === 'paraclete') continue;
|
||
const fullPath = join(CANONS_DIR, entry, 'sunday.json');
|
||
if (!existsSync(fullPath)) {
|
||
warn(`Missing ${fullPath}`);
|
||
continue;
|
||
}
|
||
|
||
console.log(`\n=== ${entry} ===`);
|
||
|
||
let data: CanonData;
|
||
try {
|
||
data = JSON.parse(readFileSync(fullPath, 'utf-8'));
|
||
} catch (e) {
|
||
err(`${fullPath}: failed to parse: ${(e as Error).message}`);
|
||
continue;
|
||
}
|
||
|
||
// Validate raw data structure
|
||
if (!data.tone || typeof data.tone !== 'number' || data.tone < 1 || data.tone > 8)
|
||
err(`${entry}: invalid tone ${data.tone}`);
|
||
if (!Array.isArray(data.odes))
|
||
err(`${entry}: odes must be an array`);
|
||
|
||
// Validate each ode
|
||
for (let oi = 0; oi < data.odes.length; oi++) {
|
||
const ode = data.odes[oi];
|
||
const pfx = `${entry}/odes[${oi}]/ode${ode.ode}`;
|
||
|
||
if (!ode.ode || typeof ode.ode !== 'number' || ode.ode < 1 || ode.ode > 9)
|
||
err(`${pfx}: invalid ode number`);
|
||
if (oi > 0 && data.odes[oi - 1].ode >= ode.ode)
|
||
err(`${pfx}: ode not in ascending order (after ${data.odes[oi - 1].ode})`);
|
||
if (!Array.isArray(ode.canons))
|
||
err(`${pfx}: canons must be an array`);
|
||
if (ode.canons.length !== 3)
|
||
warn(`${pfx}: ${ode.canons.length} canons (expected 3)`);
|
||
|
||
for (let ci = 0; ci < ode.canons.length; ci++) {
|
||
const c = ode.canons[ci];
|
||
const cp = `${pfx}/canon${ci}`;
|
||
|
||
if (typeof c.irmos !== 'string') err(`${cp}: irmos must be string`);
|
||
if (!Array.isArray(c.troparia)) err(`${cp}: troparia must be array`);
|
||
if (typeof c.theotokion !== 'string') err(`${cp}: theotokion must be string`);
|
||
if (c.troparia.length === 0) warn(`${cp}: zero troparia`);
|
||
for (let ti = 0; ti < c.troparia.length; ti++) {
|
||
if (typeof c.troparia[ti] !== 'string' || c.troparia[ti].length < 5)
|
||
err(`${cp}/troparia[${ti}]: too short (${c.troparia[ti]?.length ?? 0} chars)`);
|
||
}
|
||
|
||
// theotokion may be empty — source text often lacks explicit Бгородиченъ:
|
||
// Cross-Resurrection canon (ci=1) and Theotokos canon (ci=2) frequently
|
||
// have no separate theotokion marker in the Oktoih source.
|
||
}
|
||
}
|
||
|
||
// Simulate loadCanonData() and validate ServiceNode output
|
||
const nodes = simulateLoadCanonData(data);
|
||
if (nodes.length === 0) {
|
||
err(`${entry}: zero ServiceNodes produced`);
|
||
continue;
|
||
}
|
||
|
||
let prevType = '';
|
||
let odeCount = 0;
|
||
let canonCount = 0;
|
||
let expectingIrmosText = false;
|
||
let expectingTheotokionText = false;
|
||
let inCanon = false;
|
||
|
||
for (let ni = 0; ni < nodes.length; ni++) {
|
||
const n = nodes[ni];
|
||
const np = `${entry}/node[${ni}]`;
|
||
|
||
if (n.type !== 'HEADER' && n.type !== 'TEXT')
|
||
err(`${np}: invalid type '${n.type}'`);
|
||
if (!n.value || typeof n.value !== 'string')
|
||
err(`${np}: missing or invalid value`);
|
||
|
||
// Check ode header
|
||
if (n.type === 'HEADER' && /^Пѣснь [а-ѳ]҃\.$/.test(n.value)) {
|
||
odeCount++;
|
||
canonCount = 0;
|
||
inCanon = false;
|
||
continue;
|
||
}
|
||
|
||
// Check irmos header
|
||
if (n.type === 'HEADER' && n.value.startsWith('Ірмосъ')) {
|
||
canonCount++;
|
||
if (canonCount > 3) warn(`${np}: more than 3 canons in ode`);
|
||
expectingIrmosText = true;
|
||
inCanon = true;
|
||
continue;
|
||
}
|
||
|
||
// Check theotokion header
|
||
if (n.type === 'HEADER' && n.value.startsWith('Бг҃ородиченъ')) {
|
||
expectingTheotokionText = true;
|
||
continue;
|
||
}
|
||
|
||
// Consume irmos text
|
||
if (expectingIrmosText) {
|
||
if (n.type !== 'TEXT') err(`${np}: expected TEXT after irmos HEADER`);
|
||
expectingIrmosText = false;
|
||
continue;
|
||
}
|
||
|
||
// Consume theotokion text
|
||
if (expectingTheotokionText) {
|
||
if (n.type !== 'TEXT') err(`${np}: expected TEXT after Бг҃ородиченъ HEADER`);
|
||
expectingTheotokionText = false;
|
||
continue;
|
||
}
|
||
|
||
prevType = n.type;
|
||
}
|
||
|
||
if (odeCount === 0) err(`${entry}: no ode headers found`);
|
||
if (expectingIrmosText) err(`${entry}: missing irmos TEXT after last Ірмосъ HEADER`);
|
||
if (expectingTheotokionText) err(`${entry}: missing Бг҃ородиченъ TEXT after last HEADER`);
|
||
|
||
console.log(` ${data.odes.length} odes, ${nodes.length} ServiceNodes`);
|
||
}
|
||
|
||
console.log(`\n${errors + warnings} issues (${errors} errors, ${warnings} warnings)`);
|
||
process.exit(errors > 0 ? 1 : 0);
|