diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 1bfd301..5297c02 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -21,6 +21,7 @@ import { isFerialOrVigil, getOfficeOverrideId, resolveResponsory, + resolveOfficeBundle, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -178,29 +179,26 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { return parts; } -/** A duplex-majus+/Marian-Saturday override's own capitulum/responsory/ - * hymn/versicle (data/propers/common/lauds-{part}-${id}.yml), when - * authored — most overrides only bother with the psalmody (see - * getPsalmodyOverrideFor) and leave this bundle at its plain weekday - * default, which is exactly what falls back here when the id-keyed - * capitulum doesn't exist (same dual-missing-status "not authored" - * signal used throughout this codebase, not a special case). Falls to a - * *seasonal* default next (Advent/Lent/Passiontide/Paschaltide — see - * resolve-common.ts's seasonalOfficeSuffix), and only then the plain - * weekday default — a feast's own override always wins over the season - * it happens to fall in, same as everywhere else in this codebase. */ +/** Any sanctoral winner's (or named-temporal feast's) own capitulum/ + * responsory/hymn/versicle (data/propers/common/lauds-{part}-${id}.yml), + * else their Common's (`${part}-${common}`), when authored — see + * resolve-common.ts's resolveOfficeBundle for the eligibility/fallback + * rule (deliberately rank-agnostic as of 2026-08-25: a simplex feast's + * Common chapter/hymn is real content, not gated behind duplex-majus+ + * like the psalmody override above is). Falls to a *seasonal* default + * next (Advent/Lent/Passiontide/Paschaltide — see resolve-common.ts's + * seasonalOfficeSuffix), and only then the plain weekday default — a + * feast's own override always wins over the season it happens to fall + * in, same as everywhere else in this codebase. */ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { - const overrideId = getPsalmodyOverrideFor(day)?.id; - if (overrideId) { - const chapter = resolveCommon(`lauds-capitulum-${overrideId}`); - if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { - return [ - { kind: 'chapter', text: chapter }, - { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${overrideId}`), day) }, - { kind: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) }, - { kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) }, - ]; - } + const bundle = resolveOfficeBundle('lauds', day); + if (bundle) { + return [ + { kind: 'chapter', text: bundle.chapter }, + { kind: 'responsory', text: bundle.responsory }, + { kind: 'hymn', text: bundle.hymn }, + { kind: 'versicle', text: bundle.versicle }, + ]; } const seasonSuffix = seasonalOfficeSuffix(day.season); const key = seasonSuffix ?? day.weekday; diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 89541b3..6e84cc3 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -401,6 +401,66 @@ export function resolveMinorHourChapter(hourId: string, day: LiturgicalDay, fall return resolveCommon(fallbackId); } +export interface OfficeBundle { + chapter: ResolvedText; + responsory: ResolvedText; + hymn: ResolvedText; + versicle: ResolvedText; +} + +/** + * The chapter/responsory/hymn/versicle bundle following Lauds'/Vespers' + * psalmody (hours/lauds.ts's and hours/vespers.ts's own resolveOffice) — + * a saint's own proper bundle (`${hourId}-{part}-${id}`) when authored, + * else that saint's Common bundle (`${hourId}-{part}-${commonId}`, + * SaintRecord's main `common` field, not `minorHoursCommon` — this is the + * saint's overall classification, the same one driving their day collect). + * Eligibility is deliberately rank-agnostic, `getMinorHourOverrideId`'s + * test (any sanctoral winner, or a named ALWAYS_OVERRIDE_TEMPORAL_IDS + * feast) rather than resolveOffice's old duplex-majus+-only gate: a + * simplex feast's own or Common's chapter/hymn is real, distinct + * liturgical content, not something only strong feasts are entitled to — + * confirmed 2026-08-25 (St. Louis, King of France, simplex, was silently + * falling all the way to the plain ferial default instead of his Common + * of a Confessor Not Bishop). Same honest "eligible, not gated on whether + * content exists" convention as everywhere else: returns `undefined` when + * neither the proper nor the Common has this bundle authored yet, so the + * caller falls through to its own seasonal/weekday default. + * + * `resolveChapterFor` lets Vespers reuse its own chapter lookup (which + * itself falls back to the byte-identical Lauds capitulum file when no + * `vespers-capitulum-.yml` exists — see vespersCapitulumForOverride) + * instead of the plain `${hourId}-capitulum-${id}` default used here. + */ +export function resolveOfficeBundle( + hourId: string, + day: LiturgicalDay, + resolveChapterFor: (id: string) => ResolvedText = (id) => resolveCommon(`${hourId}-capitulum-${id}`), +): OfficeBundle | undefined { + const overrideId = getMinorHourOverrideId(day); + if (!overrideId) { + return undefined; + } + const tryId = (id: string): OfficeBundle | undefined => { + const chapter = resolveChapterFor(id); + if (chapter.status.la === 'missing' && chapter.status.en === 'missing') { + return undefined; + } + return { + chapter, + responsory: resolveResponsory(resolveCommon(`${hourId}-responsory-${id}`), day), + hymn: resolveCommon(`${hourId}-hymn-${id}`), + versicle: resolveCommon(`${hourId}-versicle-${id}`), + }; + }; + const proper = tryId(overrideId); + if (proper) { + return proper; + } + const commonId = getSaintRecord(overrideId)?.common; + return commonId ? tryId(commonId) : undefined; +} + /** Fills a Common collect template's literal `{N}` placeholders with a * saint's own name(s) per language (SaintRecord.collectName) — the same * substitution the reference source's own Commune files perform diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index f07c926..cee9a22 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -15,8 +15,8 @@ import { verifiedText, seasonalOfficeSuffix, isFerialOrVigil, - getOfficeOverrideId, resolveResponsory, + resolveOfficeBundle, } from './resolve-common'; import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml'; @@ -109,31 +109,30 @@ function vespersCapitulumForOverride(id: string): ResolvedText { } /** The chapter/responsory/hymn/versicle bundle following the psalmody — - * see hours/types.ts's 'vespers-office' doc comment. A duplex-majus+ - * saint's own proper bundle (chapter via vespersCapitulumForOverride - * above, responsory/hymn/versicle via `vespers-{part}-${id}`), or one of - * the named temporal feasts eligible via resolve-common.ts's - * getOfficeOverrideId, wins first when authored — same eligible-but-not- - * gated-on-content pattern as Lauds' resolveOffice, just without a - * psalmody-override table of its own to piggyback eligibility on - * (Vespers has no per-feast psalmody override mechanism — see TODO.md). - * Falls to a *seasonal* default next (Advent/Lent/Passiontide/ - * Paschaltide — see resolve-common.ts's seasonalOfficeSuffix), and only - * then the plain weekday default — a feast's own override always wins - * over the season it happens to fall in, same as everywhere else in this - * codebase. */ + * see hours/types.ts's 'vespers-office' doc comment. Any sanctoral + * winner's (or named-temporal feast's) own proper bundle (chapter via + * vespersCapitulumForOverride above, responsory/hymn/versicle via + * `vespers-{part}-${id}`), else their Common's bundle, wins first when + * authored — resolve-common.ts's resolveOfficeBundle, deliberately + * rank-agnostic (2026-08-25: matches Lauds' identical change, ditto for + * the same reason — a simplex feast's Common office is real content), + * not gated at duplex-majus+ the way this hour's lack of its own + * psalmody-override table might otherwise suggest (Vespers has no + * per-feast psalmody override mechanism — see TODO.md; that's unrelated + * to this bundle's own eligibility). Falls to a *seasonal* default next + * (Advent/Lent/Passiontide/Paschaltide — see resolve-common.ts's + * seasonalOfficeSuffix), and only then the plain weekday default — a + * feast's own override always wins over the season it happens to fall + * in, same as everywhere else in this codebase. */ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { - const overrideId = getOfficeOverrideId(day); - if (overrideId) { - const chapter = vespersCapitulumForOverride(overrideId); - if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { - return [ - { kind: 'chapter', text: chapter }, - { kind: 'responsory', text: resolveResponsory(resolveCommon(`vespers-responsory-${overrideId}`), day) }, - { kind: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) }, - { kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) }, - ]; - } + const bundle = resolveOfficeBundle('vespers', day, vespersCapitulumForOverride); + if (bundle) { + return [ + { kind: 'chapter', text: bundle.chapter }, + { kind: 'responsory', text: bundle.responsory }, + { kind: 'hymn', text: bundle.hymn }, + { kind: 'versicle', text: bundle.versicle }, + ]; } const seasonSuffix = seasonalOfficeSuffix(day.season); const key = seasonSuffix ?? day.weekday;