Centralize antiphon opening-doubling logic across all hours

The same idiom (splitNamedAntiphon + isDoubleOrHigher(...) ? full :
incipit) was copy-pasted at 11 call sites across 8 hour files. Add one
shared openingAntiphon(antiphon, winner) helper in resolve-common.ts
and use it everywhere a psalm/canticle antiphon's opening form is
decided. Pure dedup, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
This commit is contained in:
2026-08-24 07:13:38 -04:00
parent f8de318b7f
commit 8f55ec6931
9 changed files with 55 additions and 36 deletions
+4 -3
View File
@@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology';
import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './antiphon';
import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common';
import { resolveCommon, appendDoxology, splitNamedAntiphon, openingAntiphon, resolveResponsory } from './resolve-common';
import complineDefinitionData from '../data/hours/compline.yml';
const complineDefinition = complineDefinitionData as HourDefinition;
@@ -63,8 +63,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
},
];
case 'nunc-dimittis': {
const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon'));
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
const antiphonText = resolveCommon('nunc-dimittis-antiphon');
const opening = openingAntiphon(antiphonText, day.winner);
const { full } = splitNamedAntiphon(antiphonText);
return [
{ kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening },
{ kind: 'antiphon', text: full },
+6 -6
View File
@@ -14,6 +14,7 @@ import {
getDayCollects,
getBenedictusAntiphon,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
verifiedText,
seasonalOfficeSuffix,
@@ -134,10 +135,8 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
* plain weekday default below gets substituted. */
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday];
const opening = (antiphon: BilingualText) => {
const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon));
return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
};
const winner = resolveOfficeWinner(day);
const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner);
const parts: ResolvedPart[] = [
{
kind: 'psalm',
@@ -237,8 +236,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'lauds-office':
return resolveOffice(day);
case 'benedictus': {
const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const antiphonText = getBenedictusAntiphon(day);
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
return [
{ kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening },
{ kind: 'antiphon', text: full },
+11 -7
View File
@@ -78,6 +78,7 @@ import {
seasonalOfficeSuffix,
verifiedText,
splitNamedAntiphon,
openingAntiphon,
} from './resolve-common';
import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
@@ -187,8 +188,9 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
const parts: ResolvedPart[] = [];
for (const g of group.groups ?? []) {
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const antiphonText = verifiedText(g.antiphon);
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
g.psalms.forEach((n, i) => {
parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined));
});
@@ -201,8 +203,9 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP
/** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see
* data/hours/matins-sunday-antiphons.yml's own header. */
function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {}));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const antiphonText = verifiedText(group.antiphon ?? {});
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
const text: BilingualText = {
@@ -285,11 +288,12 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
* as one combined V./R. block via `versicleText`. */
function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>];
const isDouble = isDoubleOrHigher(resolveOfficeWinner(day));
const winner = resolveOfficeWinner(day);
const parts: ResolvedPart[] = [];
for (const group of nocturn.groups) {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
const opening = isDouble ? full : incipit;
const antiphonText = verifiedText(group.antiphon);
const opening = openingAntiphon(antiphonText, winner);
const { full } = splitNamedAntiphon(antiphonText);
group.psalms.forEach((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+3 -4
View File
@@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import { applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
resolveMinorHourAntiphon,
resolveMinorHourChapter,
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('none', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const opening = openingAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]), resolveOfficeWinner(day));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+3 -2
View File
@@ -14,6 +14,7 @@ import {
resolveCommon,
appendDoxology,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
resolveMinorHourAntiphon,
isSundayOrFeastOffice,
@@ -65,11 +66,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
}
{
const psalmRefs = getPsalmsFor('prime', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]));
const antiphonText = resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]);
// Full text on a Double-or-higher feast, otherwise just the incipit
// — see hours/antiphon.ts. The full repeat comes later, after the
// Creed (see 'closing-antiphon'), not tacked onto the last psalm.
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+14 -1
View File
@@ -6,7 +6,7 @@ import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon } from './antiphon';
import { splitAntiphon, isDoubleOrHigher } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
type BilingualText = Partial<Record<string, string>>;
@@ -692,6 +692,19 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
}
/** The antiphon actually shown *before* a psalm/canticle: full text on a
* Double-rank winner or higher, incipit only below that (see
* `isDoubleOrHigher`'s own doc comment for the underlying rule) — the one
* idiom every hour's psalmody independently repeated (`splitNamedAntiphon`
* + `isDoubleOrHigher(...) ? full : incipit`) before being centralized
* here. The closing repeat after the psalm/canticle is always `full`,
* unconditionally, at each call site — this helper only decides the
* opening. */
export function openingAntiphon(antiphon: ResolvedText, winner: DayWinner): ResolvedText {
const { incipit, full } = splitNamedAntiphon(antiphon);
return isDoubleOrHigher(winner) ? full : incipit;
}
/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri —
* appended after every psalm (hours/index.ts) except during the Sacred
* Triduum. Not "verified" via verifiedText() because it's boilerplate
+3 -4
View File
@@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import { applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
resolveMinorHourAntiphon,
resolveMinorHourChapter,
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('sext', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const opening = openingAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]), resolveOfficeWinner(day));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+3 -4
View File
@@ -5,11 +5,11 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import { applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollect,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
resolveMinorHourAntiphon,
resolveMinorHourChapter,
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('terce', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const opening = openingAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]), resolveOfficeWinner(day));
return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+8 -5
View File
@@ -4,12 +4,13 @@ import { resolveEveningDay } from '../calendar/vespers';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
import { applyFlexaMark } from './antiphon';
import {
resolveCommon,
getDayCollects,
getMagnificatAntiphon,
splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner,
verifiedText,
seasonalOfficeSuffix,
@@ -47,8 +48,9 @@ function normalizePsalmRef(ref: PsalmRefLike): { number: number; verses?: string
* in full once, after the last. Mirrors lauds.ts's psalmParts, extended
* for verse-range entries (see PsalmRefLike). */
function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const antiphonText = verifiedText(group.antiphon);
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
const refs = group.psalms.map(normalizePsalmRef);
const parts: ResolvedPart[] = refs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
@@ -161,8 +163,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'vespers-office':
return resolveOffice(day);
case 'magnificat': {
const { incipit, full } = splitNamedAntiphon(getMagnificatAntiphon(day));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const antiphonText = getMagnificatAntiphon(day);
const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
return [
{ kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening },
{ kind: 'antiphon', text: full },