From 7858692fa79bf52fd8200fa8b4b5f529f82d8147 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sun, 23 Aug 2026 06:30:58 -0400 Subject: [PATCH] Wire the flexa mark into every hour's psalm-antiphon pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every psalm's opening antiphon (Lauds, Prime, Terce, Sext, None, Vespers, Compline's callers, Matins) now runs through applyFlexaMark, marking the boundary in both the displayed antiphon and the psalm's first verse — e.g. today's Sunday Matins Nocturn 1: "Ant. The king rejoices ‡" / "The king rejoices ‡ in thy strength, O Lord...". Live-verified against the reference engine's own rendering for Ps 20. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UCvvgFLrhXNVFUThz6kanw --- src/hours/lauds.ts | 18 +++++++++++------- src/hours/matins.ts | 21 ++++++++++++++++----- src/hours/none.ts | 18 +++++++++++------- src/hours/prime.ts | 18 +++++++++++------- src/hours/sext.ts | 18 +++++++++++------- src/hours/terce.ts | 18 +++++++++++------- src/hours/vespers.ts | 18 +++++++++++------- tests/hours/flexa.test.ts | 16 ++++++++++++++++ 8 files changed, 98 insertions(+), 47 deletions(-) create mode 100644 tests/hours/flexa.test.ts diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 7e2ac0b..4401cba 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -8,7 +8,7 @@ import { getCanticle } from './lauds-canticles'; import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, @@ -81,12 +81,16 @@ function capitulumId(weekday: Weekday): string { } function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] { - const parts: ResolvedPart[] = numbers.map((number, i) => ({ - kind: 'psalm' as const, - psalmNumber: number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + const parts: ResolvedPart[] = numbers.map((number, i) => { + const rawVerses = getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })); + const { verses, antiphon: psalmAntiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); + return { + kind: 'psalm' as const, + psalmNumber: number, + antiphon: psalmAntiphon, + verses, + }; + }); parts.push({ kind: 'antiphon', text: splitNamedAntiphon(verifiedText(antiphon)).full }); return parts; } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index fdebcbe..02f5000 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -69,7 +69,7 @@ import { getPsalmVerses } from '../psalter'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; import { getScriptureVerses } from '../scripture'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -150,6 +150,15 @@ function plainPsalm(number: number): PsalmPart { }; } +/** `plainPsalm` plus the given antiphon, with the flexa mark (see + * hours/antiphon.ts's `applyFlexaMark`) applied to the first verse against + * that antiphon — a no-op when `antiphon` is undefined. */ +function psalmPartWithAntiphon(number: number, rawAntiphon: ResolvedText | undefined): PsalmPart { + const base = plainPsalm(number); + const { verses, antiphon } = applyFlexaMark(base.verses, rawAntiphon); + return { ...base, antiphon, verses }; +} + function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] { return refs.map((ref) => ({ kind: 'psalm' as const, @@ -167,7 +176,7 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon')); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return [ - { ...plainPsalm(94), antiphon: opening }, + psalmPartWithAntiphon(94, opening), { kind: 'antiphon', text: full }, ]; } @@ -181,7 +190,7 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; g.psalms.forEach((n, i) => { - parts.push({ ...plainPsalm(n), antiphon: i === 0 ? opening : undefined }); + parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined)); }); parts.push({ kind: 'antiphon', text: full }); } @@ -282,11 +291,13 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const opening = isDouble ? full : incipit; 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); parts.push({ kind: 'psalm', psalmNumber: ref.number, - verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })), - antiphon: i === 0 ? opening : undefined, + verses, + antiphon, }); }); parts.push({ kind: 'antiphon', text: full }); diff --git a/src/hours/none.ts b/src/hours/none.ts index 3aa1022..c6a32aa 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('none', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday])); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; - return psalmRefs.map((ref, i) => ({ - kind: 'psalm' as const, - psalmNumber: ref.number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + 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); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); } // Not used by None. case 'prayer': diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 7ce44e1..a7cc4ca 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -9,7 +9,7 @@ import { getRegulaReadingFor } from '../regula'; import { getChapterResponsoryId } from './chapter-responsory'; import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, appendDoxology, @@ -70,12 +70,16 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved // — 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; - return psalmRefs.map((ref, i) => ({ - kind: 'psalm' as const, - psalmNumber: ref.number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + 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); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); } case 'psalm': return [ diff --git a/src/hours/sext.ts b/src/hours/sext.ts index 6fdedb7..b012954 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('sext', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday])); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; - return psalmRefs.map((ref, i) => ({ - kind: 'psalm' as const, - psalmNumber: ref.number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + 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); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); } // Not used by Sext. case 'prayer': diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 48b2e21..97eb054 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -5,7 +5,7 @@ import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -40,12 +40,16 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { const psalmRefs = getPsalmsFor('terce', day.weekday); const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday])); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; - return psalmRefs.map((ref, i) => ({ - kind: 'psalm' as const, - psalmNumber: ref.number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + 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); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); } // Not used by Terce. case 'prayer': diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index 3091033..6c1da84 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -4,7 +4,7 @@ import { resolveEveningDay } from '../calendar/vespers'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher } from './antiphon'; +import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, @@ -50,12 +50,16 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const refs = group.psalms.map(normalizePsalmRef); - const parts: ResolvedPart[] = refs.map((ref, i) => ({ - kind: 'psalm' as const, - psalmNumber: ref.number, - antiphon: i === 0 ? opening : undefined, - verses: getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })), - })); + 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 })); + const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); + return { + kind: 'psalm' as const, + psalmNumber: ref.number, + antiphon, + verses, + }; + }); parts.push({ kind: 'antiphon', text: full }); return parts; } diff --git a/tests/hours/flexa.test.ts b/tests/hours/flexa.test.ts new file mode 100644 index 0000000..91fce37 --- /dev/null +++ b/tests/hours/flexa.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { resolveOrdo } from '../../src/hours'; + +describe('resolveOrdo("matins", ...) flexa mark on Ps 20', () => { + it("marks where today's opening antiphon leaves off, in both the antiphon itself and Ps 20's first verse (Sunday Matins, Nocturn 1, 2026-08-23)", () => { + const ordo = resolveOrdo('matins', '2026-08-23'); + const ps20 = ordo.parts.find((p) => p.kind === 'psalm' && p.psalmNumber === 20); + expect(ps20).toBeDefined(); + if (ps20?.kind !== 'psalm') throw new Error('expected a psalm part'); + const firstVerse = ps20.verses[0]; + expect(firstVerse?.text.la).toContain('‡'); + expect(firstVerse?.text.en).toContain('‡'); + expect(ps20.antiphon?.text.la).toContain('‡'); + expect(ps20.antiphon?.text.en).toContain('‡'); + }); +});