import { getState } from '../app-state';
import { resolveOrdo } from '../hours';
import type { ResolvedPart, ResolvedText, ResolvedVerse } from '../hours/types';
import { hourLabel } from './format';
function escapeHtml(value: string): string {
return value.replace(/&/g, '&').replace(//g, '>');
}
function cellFor(resolved: ResolvedText | ResolvedVerse, lang: string): string {
const status = resolved.status[lang] ?? 'missing';
const text = resolved.text[lang];
if (status === 'missing' || !text) {
return '(translation pending)';
}
const escaped = escapeHtml(text);
return status === 'draft' ? `${escaped}` : escaped;
}
function renderColumns(resolved: ResolvedText | ResolvedVerse, languages: readonly string[]): string {
const cols = languages
.map((lang) => `
${cellFor(resolved, lang)}
`)
.join('');
return `${cols}
`;
}
// Scripture quotations always carry a full citation (book, chapter, verse) —
// shown once, deduplicated across languages when they happen to match.
function renderCitation(text: ResolvedText): string {
if (!text.citation) {
return '';
}
const values = [...new Set(Object.values(text.citation).filter((v): v is string => Boolean(v)))];
return values.length ? `${escapeHtml(values.join(' / '))}
` : '';
}
function renderPart(part: ResolvedPart, languages: readonly string[]): string {
switch (part.kind) {
case 'hymn':
case 'chapter':
case 'prayer':
return `
${hourLabel(part.kind)}
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
`;
// Short liturgical dialogue — reads fine without a jargon label.
case 'responsory':
case 'versicle':
return `
${renderColumns(part.text, languages)}
`;
case 'antiphon':
return `
${renderColumns(part.text, languages)}
`;
case 'preces':
return `
${part.label ? `${escapeHtml(part.label)}
` : ''}
${renderColumns(part.text, languages)}
`;
case 'lesson':
return `
${part.isGospel ? 'Gospel' : part.label ? escapeHtml(part.label) : 'Reading'}
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.responsory ? `` : ''}
`;
case 'psalm': {
// Always cite the full range, not just the psalm number — a 4-verse
// slice of Psalm 118 is a different citation from the whole psalm.
const first = part.verses[0]?.n;
const last = part.verses[part.verses.length - 1]?.n;
const citation =
first === undefined
? `Psalm ${part.psalmNumber}`
: first === last
? `Psalm ${part.psalmNumber}:${first}`
: `Psalm ${part.psalmNumber}:${first}-${last}`;
return `
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
${citation}
${part.verses.map((v) => `- ${renderColumns(v, languages)}
`).join('')}
${part.gloriaPatri ? `${renderColumns(part.gloriaPatri, languages)}
` : ''}
`;
}
case 'canticle': {
const title = part.canticleId
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return `
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
${escapeHtml(title)}
${renderCitation(part.text)}
${renderColumns(part.text, languages)}
${part.gloriaPatri ? `${renderColumns(part.gloriaPatri, languages)}
` : ''}
`;
}
// Matins only. Not currently emitted by hours/matins.ts (its psalms
// render as a flat sequence of plain 'psalm'/'antiphon'/'versicle'
// parts instead) — handled here for the ResolvedPart union's sake,
// ready for whenever nocturn-grouped UI treatment is wanted.
case 'nocturn-psalmody':
return `
Nocturn ${part.nocturn}
${part.psalms.map((p) => renderPart(p, languages)).join('')}
`;
case 'section-heading':
return `${escapeHtml(part.label)}
`;
case 'te-deum':
return `
Te Deum
${renderColumns(part.text, languages)}
`;
}
}
export function renderHourView(container: HTMLElement): void {
const { date, selectedHour, languages } = getState();
const ordo = resolveOrdo(selectedHour, date);
if (ordo.notImplemented) {
container.innerHTML = `
${hourLabel(selectedHour)}
This hour hasn't been built yet.
`;
return;
}
container.innerHTML = `
${hourLabel(selectedHour)}
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
`;
}