Files
2026-08-01 02:43:24 +03:00

196 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);