Make octave-reading source attribution bilingual

The heading above each Matins octave reading was a bare Latin string
(e.g. "Sermo sancti Bernárdi Abbátis") rendered as-is in both the
Latin and English columns, since the app shows both languages side
by side rather than toggling. Widen OctaveReadingText.source and the
lesson part's label to carry per-language text, and render it as its
own lang-columns row like the body text beneath it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3wxvz3mPXiHxkPB5JpnmD
This commit is contained in:
2026-08-21 08:04:03 -04:00
parent a7da8b0211
commit b52b202909
5 changed files with 41 additions and 9 deletions
+8 -2
View File
@@ -232,7 +232,13 @@ export type ResolvedPart =
// groups a reading under its 'nocturn-psalmody' sibling for the UI (1-3, // groups a reading under its 'nocturn-psalmody' sibling for the UI (1-3,
// omitted on a ferial 1-nocturn day); `source` is a human-readable // omitted on a ferial 1-nocturn day); `source` is a human-readable
// attribution ("St. John Chrysostom, Homily 3 on Matthew"; the user's // attribution ("St. John Chrysostom, Homily 3 on Matthew"; the user's
// own bible-reading plan doesn't carry one). `isGospel` flags a reading // own bible-reading plan doesn't carry one). `label` is usually that same
// plain string (nocturn-readings' own `source`, already written once in
// English and reused as-is in both language columns), but octave
// readings' own `source` is a genuine bilingual attribution (their
// English title is a real translation of a Latin incipit, not an
// editorial description written once) so `label` accepts either shape —
// see hour-view.ts's renderLessonLabel. `isGospel` flags a reading
// as a Gospel pericope (from either the user's own continuous-reading // as a Gospel pericope (from either the user's own continuous-reading
// plan or the day's own proper Gospel+homily — never a Common-of-Saints // plan or the day's own proper Gospel+homily — never a Common-of-Saints
// fallback Gospel, which this app deliberately excludes for Matins, see // fallback Gospel, which this app deliberately excludes for Matins, see
@@ -245,7 +251,7 @@ export type ResolvedPart =
| { | {
kind: 'lesson'; kind: 'lesson';
text: ResolvedText; text: ResolvedText;
label?: string; label?: string | Partial<Record<string, string>>;
nocturn?: number; nocturn?: number;
source?: string; source?: string;
isGospel?: boolean; isGospel?: boolean;
+6 -4
View File
@@ -21,9 +21,11 @@ export interface ScriptureCitation {
export interface OctaveReadingText { export interface OctaveReadingText {
id: string; id: string;
/** Attribution for the reading — author and work, e.g. "St. John /** Attribution for the reading — author and work, e.g. "St. John
* Damascene, 2nd Sermon on the Dormition of the Mother of God". Not * Damascene, 2nd Sermon on the Dormition of the Mother of God". Bilingual
* itself translated/localized. */ * like everything else here since it's rendered as the reading's own
source: string; * heading in both language columns (2026-08-21 fix: it used to be a bare
* Latin incipit shown verbatim in the English column too). */
source: Partial<Record<LanguageCode, string>>;
text: Partial<Record<LanguageCode, string>>; text: Partial<Record<LanguageCode, string>>;
/** The first responsory following the reading in the source, where one /** The first responsory following the reading in the source, where one
* was found — which one to use when several lessons (and several * was found — which one to use when several lessons (and several
@@ -51,7 +53,7 @@ export interface OctaveReadingText {
*/ */
interface OctaveReadingRecord { interface OctaveReadingRecord {
id: string; id: string;
source: string; source: Partial<Record<LanguageCode, string>>;
text?: Partial<Record<LanguageCode, string>>; text?: Partial<Record<LanguageCode, string>>;
passages?: ScriptureCitation[]; passages?: ScriptureCitation[];
responsory?: Partial<Record<LanguageCode, string>>; responsory?: Partial<Record<LanguageCode, string>>;
+22 -1
View File
@@ -34,6 +34,27 @@ function renderCitation(text: ResolvedText): string {
return values.length ? `<p class="ordo-part-citation">${escapeHtml(values.join(' / '))}</p>` : ''; return values.length ? `<p class="ordo-part-citation">${escapeHtml(values.join(' / '))}</p>` : '';
} }
// A lesson's `label` is either a plain string (an editorial attribution
// written once and reused in both language columns) or a per-language
// object (a genuine translation, e.g. an octave reading's Latin incipit
// vs. its English title) — render the latter as its own lang-columns row
// matching the body text underneath it.
function renderLessonLabel(
label: string | Partial<Record<string, string>> | undefined,
languages: readonly string[],
): string {
if (!label) {
return '<h3 class="ordo-part-label">Reading</h3>';
}
if (typeof label === 'string') {
return `<h3 class="ordo-part-label">${escapeHtml(label)}</h3>`;
}
const cols = languages
.map((lang) => `<span class="lang-column" lang="${lang}">${escapeHtml(label[lang] ?? '')}</span>`)
.join('');
return `<h3 class="ordo-part-label lang-columns lang-columns-${languages.length}">${cols}</h3>`;
}
function renderPart(part: ResolvedPart, languages: readonly string[]): string { function renderPart(part: ResolvedPart, languages: readonly string[]): string {
switch (part.kind) { switch (part.kind) {
case 'hymn': case 'hymn':
@@ -70,7 +91,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
case 'lesson': case 'lesson':
return ` return `
<section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}"> <section class="ordo-part ordo-part-lesson${part.isGospel ? ' ordo-part-lesson-gospel' : ''}">
<h3 class="ordo-part-label">${part.isGospel ? 'Gospel' : part.label ? escapeHtml(part.label) : 'Reading'}</h3> ${part.isGospel ? '<h3 class="ordo-part-label">Gospel</h3>' : renderLessonLabel(part.label, languages)}
${renderCitation(part.text)} ${renderCitation(part.text)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''} ${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
+3 -1
View File
@@ -90,7 +90,9 @@ describe('resolveOrdo("prime", ...)', () => {
it('resolves a Regula reading for the day, via the canonical-date table', () => { it('resolves a Regula reading for the day, via the canonical-date table', () => {
const ordo = resolveOrdo('prime', '2026-08-25'); const ordo = resolveOrdo('prime', '2026-08-25');
// 08-25 maps to canonical 04-25 (Rule ch. 67) in data/regula/table.yml. // 08-25 maps to canonical 04-25 (Rule ch. 67) in data/regula/table.yml.
const rule = ordo.parts.find((p) => p.kind === 'lesson' && p.label?.includes('67')); const rule = ordo.parts.find(
(p) => p.kind === 'lesson' && typeof p.label === 'string' && p.label.includes('67'),
);
expect(rule).toBeDefined(); expect(rule).toBeDefined();
}); });
+2 -1
View File
@@ -4,7 +4,8 @@ import { getOctaveReading } from '../../src/propers';
describe('getOctaveReading', () => { describe('getOctaveReading', () => {
it('resolves a real reading for a day that has one, collapsed into one continuous text', () => { it('resolves a real reading for a day that has one, collapsed into one continuous text', () => {
const reading = getOctaveReading('st-lawrence', 2); const reading = getOctaveReading('st-lawrence', 2);
expect(reading?.source).toBe('Sermo sancti Augustíni Epíscopi.'); expect(reading?.source.la).toBe('Sermo sancti Augustíni Epíscopi.');
expect(reading?.source.en).toBe('Sermon of St. Augustine, Bishop');
expect(reading?.text.la).toContain('Beatissimi Laurentii Martyris'); expect(reading?.text.la).toContain('Beatissimi Laurentii Martyris');
expect(reading?.text.en).toContain('most blessed Martyr, Lawrence'); expect(reading?.text.en).toContain('most blessed Martyr, Lawrence');
expect(reading?.status.la).toBe('verified'); expect(reading?.status.la).toBe('verified');