From 998c549e51d72fcd1ce9684d1b4cae1c8a949d8f Mon Sep 17 00:00:00 2001 From: Will Estes Date: Thu, 3 Sep 2026 14:02:58 -0400 Subject: [PATCH] Replace generic "Reading"/"Gospel" headings with real incipits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bible-plan readings now carry a generated bilingual incipit label (bible-book-incipits.ts) instead of falling through to the UI's generic "Reading" heading. Gospel pericopes (whether from the user's own plan or nocturn-readings.ts's proper content) get the same treatment via a new label field on the 'gospel' ResolvedPart, recovering the book from the reading's own citation when no raw book code is available. Updates 3 matins.test.ts assertions that used "no label" as a stand-in for "this is a bible-plan reading" — no longer true now that these readings carry a real label, so they now key off the label's shape (string = patristic attribution, object = generated incipit) instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AGjUyhUZJaSjiniEmnLdak --- src/hours/matins.ts | 8 ++++++-- src/hours/types.ts | 6 ++++++ src/propers/bible-plan.ts | 12 ++++++++++-- src/ui/hour-view.ts | 5 +++-- tests/hours/matins.test.ts | 27 +++++++++++++++------------ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 8016b0a..4272318 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -92,6 +92,7 @@ import { getSaintRecord } from '../calendar/feasts'; import { getBiblePlanReadings } from '../propers/bible-plan'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getOctaveReading } from '../propers/octave-readings'; +import { getGospelIncipitFromCitation } from '../propers/bible-book-incipits'; import { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodyScheme } from './matins-psalmody-overrides'; import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; import matinsSundayNocturn3OverridesData from '../data/hours/matins-sunday-nocturn3-overrides.yml'; @@ -683,10 +684,12 @@ function nocturnReadingPart(r: NocturnReading): ResolvedPart { * whereas two adjacent pool entries could be (and, before this, sometimes * were). */ function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined): ResolvedPart { + const incipit = getGospelIncipitFromCitation(r.citation); return { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, source: r.source, + label: incipit ? { la: incipit.la, en: incipit.en } : undefined, responsory: r.responsory ? { text: r.responsory, status: { la: 'verified', en: 'verified' } } : undefined, homily: homily ? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } } @@ -717,10 +720,11 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string, // this file's own header) — a bare pericope, still its own 'gospel' // kind so it gets the same distinguishing UI treatment as a proper // Gospel+homily. + const label = r.label ? { la: r.label.la ?? '', en: r.label.en ?? '' } : undefined; parts.push( r.isGospel - ? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory } - : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory }, + ? { kind: 'gospel', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label } + : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label }, ); } // Grouped by the reading's own `nocturn` tag (ascending), not by which id diff --git a/src/hours/types.ts b/src/hours/types.ts index ecda853..75dafcc 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -297,6 +297,12 @@ export type ResolvedPart = text: ResolvedText; nocturn?: number; source?: string; + /** The traditional "Léctio sancti Evangélii secúndum..."/"A reading + * from the Holy Gospel according to..." incipit, shown in place of the + * generic "Gospel" heading — see hour-view.ts's renderLessonLabel and + * bible-book-incipits.ts. Absent only when the pericope's own book + * couldn't be identified. */ + label?: string | Partial>; responsory?: ResolvedText; homily?: { source?: string; text: ResolvedText }; }; diff --git a/src/propers/bible-plan.ts b/src/propers/bible-plan.ts index e6c261b..eaf5144 100644 --- a/src/propers/bible-plan.ts +++ b/src/propers/bible-plan.ts @@ -20,6 +20,7 @@ import type { LanguageCode, TranslationStatus } from '../psalter/types'; import { resolvePassages, type ScriptureCitation } from './octave-readings'; import { getResponsoryForBook } from './matins-responsories'; +import { getBookIncipit } from './bible-book-incipits'; const GOSPEL_BOOKS = new Set(['matt', 'mark', 'luke', 'john']); @@ -37,6 +38,11 @@ export interface BiblePlanReading { * data/hours/matins-responsories-by-book.yml — undefined when nothing's * seeded for that book yet, an honest absence, not a placeholder. */ responsory?: Partial>; + /** The traditional "Léctio libri..."/"A reading from..." incipit for the + * reading's own (first) book — see bible-book-incipits.ts. Undefined only + * for an abbreviation that map doesn't recognize, which shouldn't happen + * for anything actually under src/data/scripture. */ + label?: Partial>; } interface BiblePlanReadingRecord { @@ -107,15 +113,17 @@ function resolveReading(record: BiblePlanReadingRecord, bookIndex: number): Bibl la: text.la ? 'verified' : 'missing', en: text.en ? 'verified' : 'missing', }; - const label = formatCitationLabel(record.passages); + const citationLabelText = formatCitationLabel(record.passages); const firstBook = record.passages[0]?.book; const responsory = firstBook ? getResponsoryForBook(firstBook, bookIndex)?.text : undefined; + const incipit = firstBook ? getBookIncipit(firstBook) : undefined; return { text, status, - citation: { la: label, en: label }, + citation: { la: citationLabelText, en: citationLabelText }, isGospel: isGospelReading(record.passages), responsory, + label: incipit ? { la: incipit.la, en: incipit.en } : undefined, }; } diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index e15c3b1..8df0327 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -49,9 +49,10 @@ function renderCitation(text: ResolvedText, languages: readonly string[]): strin function renderLessonLabel( label: string | Partial> | undefined, languages: readonly string[], + fallback = 'Reading', ): string { if (!label) { - return '

Reading

'; + return `

${escapeHtml(fallback)}

`; } if (typeof label === 'string') { return `

${escapeHtml(label)}

`; @@ -115,7 +116,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string { case 'gospel': return `
-

Gospel

+ ${renderLessonLabel(part.label, languages, 'Gospel')} ${renderCitation(part.text, languages)} ${renderColumns(part.text, languages)} ${part.source ? `

${escapeHtml(part.source)}

` : ''} diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 3150d65..0923a40 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -73,14 +73,17 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { }); it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { - // Filtered to unlabeled lessons only: since the temporal-cycle - // nocturn-readings sweep authored advent-1.yml (2026-08), this same - // date's Nocturn 2/3 pool also gains two labeled (patristic) lesson - // entries alongside the user's own bible-plan readings — a real, - // separate content source this test isn't about (see - // hours/matins.ts's own nocturnReadingPart, which is the only lesson - // constructor that sets `label`). - const lessons = ordo.parts.filter((p) => p.kind === 'lesson' && !p.label); + // Filtered to the user's own bible-plan lessons only: since the + // temporal-cycle nocturn-readings sweep authored advent-1.yml + // (2026-08), this same date's Nocturn 2/3 pool also gains two labeled + // (patristic) lesson entries alongside the user's own bible-plan + // readings — a real, separate content source this test isn't about. + // Bible-plan lessons carry a generated bilingual incipit `label` + // object (bible-book-incipits.ts); patristic ones carry a plain-string + // `label` (nocturnReadingPart's own `r.source` attribution) — that + // shape difference, not label presence/absence, is what distinguishes + // them since 2026-09-03. + const lessons = ordo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string'); // Plain 2 bible-plan readings on this particular clean ferial (no // commemorated saint here) — see the next test for the 3-reading case. expect(lessons).toHaveLength(2); @@ -100,9 +103,9 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { // Semiduplex threshold). 2025-12-02, a Tuesday, keeps the same 9-psalm // ferial shape as the other tests in this block. const commemoratedOrdo = resolveOrdo('matins', '2025-12-02'); - // Same unlabeled-lesson filter as the test above — advent-1.yml's + // Same bible-plan-lesson filter as the test above — advent-1.yml's // patristic content also pools into this date. - const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson' && !p.label); + const lessons = commemoratedOrdo.parts.filter((p) => p.kind === 'lesson' && typeof p.label !== 'string'); expect(lessons).toHaveLength(3); const citations = lessons.map((l) => (l as { text: { citation?: { en?: string } } }).text.citation?.en); expect(citations).toEqual(['Isa 6-7', 'Sap 2', undefined]); @@ -159,8 +162,8 @@ describe('resolveOrdo("matins", ...) Sunday (3-nocturn) branch', () => { }); it('includes the real Nocturn 2 patristic reading (Gregory on Job) and Nocturn 3 Gospel + homily as one atomic gospel part', () => { - const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string }[]; - const gregory = lessons.find((l) => l.label?.includes('Gregory')); + const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { label?: string | Record }[]; + const gregory = lessons.find((l) => typeof l.label === 'string' && l.label.includes('Gregory')); expect(gregory).toBeDefined(); const gospels = ordo.parts.filter((p) => p.kind === 'gospel') as {