Omit responsories' own closing Gloria Patri throughout Passiontide

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.
This commit is contained in:
2026-08-19 06:38:21 -04:00
parent e93fc4032a
commit 9364185f70
9 changed files with 141 additions and 7 deletions
+3 -2
View File
@@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './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'; import complineDefinitionData from '../data/hours/compline.yml';
const complineDefinition = complineDefinitionData as HourDefinition; const complineDefinition = complineDefinitionData as HourDefinition;
@@ -36,10 +36,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; return [{ kind: 'hymn', text: appendDoxology(body, doxology) }];
} }
case 'chapter': case 'chapter':
case 'responsory':
case 'versicle': case 'versicle':
case 'prayer': case 'prayer':
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
case 'responsory':
return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(part.textRef.id), day) }];
case 'preces': case 'preces':
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
return []; return [];
+3 -2
View File
@@ -19,6 +19,7 @@ import {
seasonalOfficeSuffix, seasonalOfficeSuffix,
isFerialOrVigil, isFerialOrVigil,
getOfficeOverrideId, getOfficeOverrideId,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsDefinitionData from '../data/hours/lauds.yml';
import laudsAntiphonsData from '../data/hours/lauds-antiphons.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') { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
return [ return [
{ kind: 'chapter', text: chapter }, { 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: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) },
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) },
]; ];
@@ -202,7 +203,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
const key = seasonSuffix ?? day.weekday; const key = seasonSuffix ?? day.weekday;
return [ return [
{ kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) }, { 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: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) },
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) },
]; ];
+2 -1
View File
@@ -17,6 +17,7 @@ import {
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
isSundayOrFeastOffice, isSundayOrFeastOffice,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import primeDefinitionData from '../data/hours/prime.yml'; import primeDefinitionData from '../data/hours/prime.yml';
import antiphonsData from '../data/hours/prime-antiphons.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') { if (part.resolve === 'by-season') {
// Currently only the chapter responsory's verse uses this — see // Currently only the chapter responsory's verse uses this — see
// hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime. // 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); const psalmRefs = getPsalmsFor('prime', day.weekday);
+43
View File
@@ -697,3 +697,46 @@ export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined
} }
return verifiedText(GLORIA_PATRI); 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<Record<string, string>> = {};
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 };
}
+3 -2
View File
@@ -15,6 +15,7 @@ import {
seasonalOfficeSuffix, seasonalOfficeSuffix,
isFerialOrVigil, isFerialOrVigil,
getOfficeOverrideId, getOfficeOverrideId,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersDefinitionData from '../data/hours/vespers.yml';
import vespersAntiphonsData from '../data/hours/vespers-antiphons.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') { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
return [ return [
{ kind: 'chapter', text: chapter }, { 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: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) },
{ kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) },
]; ];
@@ -137,7 +138,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
}, },
{ {
kind: 'responsory', 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}`) }, { kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) },
{ {
+35
View File
@@ -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');
});
});
+19
View File
@@ -320,3 +320,22 @@ describe('resolveOrdo("lauds", ...)', () => {
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Auróra lucis rútilat'); 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');
});
});
+20
View File
@@ -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');
});
});
+13
View File
@@ -261,3 +261,16 @@ describe('resolveOrdo("vespers", ...)', () => {
expect(versicle).toBeDefined(); 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');
});
});