From 9364185f707b3a86c5a9e5b468b0b601c77b2d8b Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 19 Aug 2026 06:38:21 -0400 Subject: [PATCH] Omit responsories' own closing Gloria Patri throughout Passiontide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A different, wider rule than the psalm/canticle Gloria (Triduum-only): responsories drop their Gloria for the whole of Passiontide (Passion Sunday through Holy Saturday), except on a Sancti feast's own day — live-verified (St. Joseph, Duplex I, 2027-03-19, landing in Passiontide that year, keeps it; an ordinary ferial day in the same window omits it, at both Compline and Prime). Every responsory's stored text differs, Gloria-bearing vs Gloria-free, by exactly one fixed-wording line, so resolve-common.ts's new resolveResponsory filters that line out rather than requiring separate per-season files or a data migration — mechanism first, per the project's own stated content-authoring order. Wired into the four hours that have a real responsory (Compline, Prime, Lauds, Vespers); Lauds/ Vespers' existing hand-authored Passiontide-proper responsories were already Gloria-free and are unaffected. --- src/hours/compline.ts | 5 +++-- src/hours/lauds.ts | 5 +++-- src/hours/prime.ts | 3 ++- src/hours/resolve-common.ts | 43 ++++++++++++++++++++++++++++++++++++ src/hours/vespers.ts | 5 +++-- tests/hours/compline.test.ts | 35 +++++++++++++++++++++++++++++ tests/hours/lauds.test.ts | 19 ++++++++++++++++ tests/hours/prime.test.ts | 20 +++++++++++++++++ tests/hours/vespers.test.ts | 13 +++++++++++ 9 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/hours/compline.ts b/src/hours/compline.ts index e9b67c8..2c9ae94 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -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 } from './resolve-common'; +import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common'; import complineDefinitionData from '../data/hours/compline.yml'; const complineDefinition = complineDefinitionData as HourDefinition; @@ -36,10 +36,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; } case 'chapter': - case 'responsory': case 'versicle': case 'prayer': return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; + case 'responsory': + return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(part.textRef.id), day) }]; case 'preces': if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { return []; diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 89e3266..7e2ac0b 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -19,6 +19,7 @@ import { seasonalOfficeSuffix, isFerialOrVigil, getOfficeOverrideId, + resolveResponsory, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -192,7 +193,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { return [ { kind: 'chapter', text: chapter }, - { kind: 'responsory', text: resolveCommon(`lauds-responsory-${overrideId}`) }, + { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${overrideId}`), day) }, { kind: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) }, ]; @@ -202,7 +203,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { const key = seasonSuffix ?? day.weekday; return [ { kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) }, - { kind: 'responsory', text: resolveCommon(`lauds-responsory-${key}`) }, + { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${key}`), day) }, { kind: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) }, ]; diff --git a/src/hours/prime.ts b/src/hours/prime.ts index 6c39285..7ce44e1 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -17,6 +17,7 @@ import { resolveOfficeWinner, resolveMinorHourAntiphon, isSundayOrFeastOffice, + resolveResponsory, } from './resolve-common'; import primeDefinitionData from '../data/hours/prime.yml'; import antiphonsData from '../data/hours/prime-antiphons.yml'; @@ -60,7 +61,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved if (part.resolve === 'by-season') { // Currently only the chapter responsory's verse uses this — see // hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime. - return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.winner)) }]; + return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(getChapterResponsoryId(day.season, day.winner)), day) }]; } { const psalmRefs = getPsalmsFor('prime', day.weekday); diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 98bd13e..53c8c31 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -697,3 +697,46 @@ export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined } return verifiedText(GLORIA_PATRI); } + +// The responsory's own closing Gloria is a DIFFERENT rule from the psalm/ +// canticle one above: wider window (all of Passiontide, Passion Sunday +// through Holy Saturday -- `season === 'passiontide'` already covers +// exactly that span, no separate date-window helper needed), and it +// doesn't apply to a Sancti (saint) feast's own day even within that +// window -- live-verified: St. Joseph (Duplex I, 2027-03-19, landing in +// Passiontide that year) keeps "In manus tuas"'s Gloria at Compline, while +// an ordinary ferial day in the same window (2027-03-15) omits it, at both +// Compline and Prime's chapter-responsory. +// +// Every currently-authored responsory -- Gloria-bearing or already +// Gloria-free (e.g. lauds-responsory-passiontide.yml, a genuinely +// different proper-of-season chant, not a stripped copy of the ordinary +// one) -- ends the same way regardless: a short "R. [repetenda]" after +// the verse, then (only when shown) this same fixed Gloria line, then +// always a final "R. [full repetenda]" line. So the two forms differ by +// exactly one known, fixed-wording line -- omitting it is a filter, not a +// data migration. +const RESPONSORY_GLORIA_LINE: BilingualText = { + la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.', + en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.', +}; + +export function omitResponsoryGloria(day: LiturgicalDay): boolean { + return day.season === 'passiontide' && day.winner.kind !== 'sanctoral'; +} + +/** Strips the responsory's closing Gloria line from `text` when the day + * calls for its omission (see omitResponsoryGloria); returns `text` + * unchanged otherwise. Safe to call on already-Gloria-free text (e.g. the + * proper-of-season responsories) -- the line just won't be found. */ +export function resolveResponsory(text: ResolvedText, day: LiturgicalDay): ResolvedText { + if (!omitResponsoryGloria(day)) { + return text; + } + const stripped: Partial> = {}; + for (const [lang, t] of Object.entries(text.text)) { + const gloriaLine = RESPONSORY_GLORIA_LINE[lang]; + stripped[lang] = t && gloriaLine ? t.split('\n').filter((line) => line !== gloriaLine).join('\n') : t; + } + return { ...text, text: stripped }; +} diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index 1ad8834..3091033 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -15,6 +15,7 @@ import { seasonalOfficeSuffix, isFerialOrVigil, getOfficeOverrideId, + resolveResponsory, } from './resolve-common'; import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml'; @@ -122,7 +123,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { return [ { kind: 'chapter', text: chapter }, - { kind: 'responsory', text: resolveCommon(`vespers-responsory-${overrideId}`) }, + { kind: 'responsory', text: resolveResponsory(resolveCommon(`vespers-responsory-${overrideId}`), day) }, { kind: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) }, ]; @@ -137,7 +138,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { }, { kind: 'responsory', - text: resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)), + text: resolveResponsory(resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)), day), }, { kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) }, { diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index 364360e..9d337bc 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -143,3 +143,38 @@ describe('resolveOrdo("compline", ...)', () => { ); }); }); + +describe('resolveOrdo("compline", ...) responsory Gloria omission', () => { + // Passiontide (Passion Sunday through Holy Saturday) is a wider window + // than the psalm/canticle Gloria's own Triduum-only rule, and doesn't + // apply on a Sancti feast's own day even within that window -- + // live-verified: 2027-03-19 is St. Joseph (Duplex I), landing inside + // Passiontide that year, keeping "In manus tuas"'s Gloria; 2027-03-15 + // is a plain ferial in the same window, and omits it. + const FERIAL_PASSIONTIDE = '2027-03-15'; + const FEAST_IN_PASSIONTIDE = '2027-03-19'; // St. Joseph, Duplex I. + const ORDINARY_DATE = '2026-08-20'; + + it('omits the Gloria on a ferial Passiontide day', () => { + const ordo = resolveOrdo('compline', FERIAL_PASSIONTIDE); + const responsory = ordo.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).not.toContain('Glória Patri'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).not.toContain( + 'Glory be to the Father', + ); + // Everything else about the responsory is untouched. + expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).toContain('Into thy hands, O Lord'); + }); + + it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => { + const ordo = resolveOrdo('compline', FEAST_IN_PASSIONTIDE); + const responsory = ordo.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri'); + }); + + it('keeps the Gloria on an ordinary date outside Passiontide', () => { + const ordo = resolveOrdo('compline', ORDINARY_DATE); + const responsory = ordo.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri'); + }); +}); diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 53b7955..b955dfe 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -320,3 +320,22 @@ describe('resolveOrdo("lauds", ...)', () => { expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Auróra lucis rútilat'); }); }); + +describe('resolveOrdo("lauds", ...) responsory Gloria omission', () => { + // Real Passiontide already routes to a wholesale different proper-of- + // season responsory (lauds-responsory-passiontide.yml, "Érue a frámea") + // that was already transcribed without a Gloria line -- this just + // confirms the shared mechanism (resolve-common.ts's resolveResponsory) + // doesn't break that, and still applies correctly on an ordinary date. + // Same Passiontide-wide, Sancti-exempt rule as Compline's -- see + // tests/hours/compline.test.ts's own describe block for the live- + // verification dates. + it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => { + const passiontideResponsory = resolveOrdo('lauds', '2027-03-15').parts.find((p) => p.kind === 'responsory'); + const ordinaryResponsory = resolveOrdo('lauds', '2026-08-20').parts.find((p) => p.kind === 'responsory'); + expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain( + 'Glória Patri', + ); + expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri'); + }); +}); diff --git a/tests/hours/prime.test.ts b/tests/hours/prime.test.ts index f2664f4..7430b83 100644 --- a/tests/hours/prime.test.ts +++ b/tests/hours/prime.test.ts @@ -224,3 +224,23 @@ describe('resolveOrdo("prime", ...)', () => { } }); }); + +describe('resolveOrdo("prime", ...) chapter-responsory Gloria omission', () => { + // Same Passiontide-wide, Sancti-exempt rule as Compline's — see + // tests/hours/compline.test.ts's own describe block for the live- + // verification dates. + it('omits the Gloria on a ferial Passiontide day, keeps it on an ordinary date', () => { + const passiontideResponsory = resolveOrdo('prime', '2027-03-15').parts.find((p) => p.kind === 'responsory'); + const ordinaryResponsory = resolveOrdo('prime', '2026-08-20').parts.find((p) => p.kind === 'responsory'); + expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain( + 'Glória Patri', + ); + expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri'); + }); + + it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => { + const ordo = resolveOrdo('prime', '2027-03-19'); // St. Joseph, Duplex I. + const responsory = ordo.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri'); + }); +}); diff --git a/tests/hours/vespers.test.ts b/tests/hours/vespers.test.ts index 712cf45..cd50a64 100644 --- a/tests/hours/vespers.test.ts +++ b/tests/hours/vespers.test.ts @@ -261,3 +261,16 @@ describe('resolveOrdo("vespers", ...)', () => { expect(versicle).toBeDefined(); }); }); + +describe('resolveOrdo("vespers", ...) responsory Gloria omission', () => { + // Same shared mechanism/rule as Lauds' — see tests/hours/lauds.test.ts's + // and tests/hours/compline.test.ts's own describe blocks. + it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => { + const passiontideResponsory = resolveOrdo('vespers', '2027-03-15').parts.find((p) => p.kind === 'responsory'); + const ordinaryResponsory = resolveOrdo('vespers', '2026-08-20').parts.find((p) => p.kind === 'responsory'); + expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain( + 'Glória Patri', + ); + expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri'); + }); +});