Model the Paschaltide alleluia suffix on Common-category antiphons
Deploy / deploy (push) Successful in 56s
Deploy / deploy (push) Successful in 56s
Live-querying settled the "sometimes 1, sometimes 2 allelúja" question: categories without their own dedicated Paschaltide source-text chain (Confessor-Bishop/Doctor, Abbot, Virgin, ...) get a mechanical single suffix appended at render time, not a rank/octave-dependent count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -606,18 +606,74 @@ tries his own id first). English wasn't rendered at all for any of his
|
||||
6 new texts (same Latin-duplicated-into-English gap as St. Scholastica/
|
||||
St. Benedict), so all 6 are `status.en: draft`.
|
||||
|
||||
**One loose thread noticed, not chased**: St. Athanasius (May 2) and
|
||||
St. Leo I (April 11), both tested in years that happened to fall within
|
||||
Eastertide, rendered their shared `common-of-a-confessor-bishop` text
|
||||
with a trailing "`allelúja`" appended to every antiphon — but the other
|
||||
4 Doctor saints (tested in non-Paschaltide months) didn't. This isn't
|
||||
the same kind of thing as the already-modeled `-paschaltide` Common
|
||||
variants (`common-of-apostles-paschaltide` etc., which are *wholly
|
||||
different wording*, not the same text plus a suffix) — looks more like
|
||||
a general seasonal "append allelúja in Paschaltide" rule this app
|
||||
doesn't model at any level yet. Not blocking (the plain, correctly-
|
||||
verified text still renders in every other season, which is most of the
|
||||
year), but worth a look if Eastertide rendering fidelity matters later.
|
||||
**A loose thread noticed, then chased down and fixed (2026-08)**: St.
|
||||
Athanasius (May 2) and St. Leo I (April 11), both tested in years that
|
||||
happened to fall within Eastertide, rendered their shared
|
||||
`common-of-a-confessor-bishop` text with a trailing "`allelúja`"
|
||||
appended to every antiphon — but the other 4 Doctor saints (tested in
|
||||
non-Paschaltide months) didn't. Live-querying further (a full sweep of
|
||||
Common categories across the Easter/Ascension/Pentecost octaves and the
|
||||
ordinary weeks around them, reading the reference source's raw
|
||||
`Commune/*p.txt` chains directly rather than guessing from rendered
|
||||
pages) settled it precisely:
|
||||
|
||||
- A handful of categories (Apostles, Martyr, Martyr-Bishop, Several
|
||||
Martyrs, Pope-Martyrs) have their own dedicated `Cxxp.txt` source
|
||||
chain — a genuinely *different* text for Paschaltide, not the same
|
||||
text plus a suffix (confirmed by reading the raw files: e.g. the
|
||||
"Fíliæ Jerúsalem..." Pope-Martyrs Benedictus antiphon comes from a
|
||||
Matins responsory reused for Lauds specifically during this window,
|
||||
nowhere near the non-Paschaltide chain's own "Mártyrum chorus..."
|
||||
text). Only `common-of-apostles` has this wholesale text actually
|
||||
authored into this app's data (`*-paschaltide.yml`, from an earlier
|
||||
pass) — the other four still fall through to the mechanical suffix
|
||||
below until someone authors their real text, which is a strictly
|
||||
better approximation than the unmodified default.
|
||||
- Every *other* category (Confessor-Bishop/Doctor, Abbot, Virgin, and
|
||||
presumably Dedication/Vigil/Angel, untested but structurally the
|
||||
same) has no dedicated chain at all — the reference engine just
|
||||
mechanically appends a single ", allelúja"/", alleluia" to the
|
||||
otherwise-unchanged base text, for the whole Eastertide → Ascensiontide
|
||||
→ Pentecost-octave-week span. Live-verified across three unrelated
|
||||
categories (St. Athanasius/Confessor-Bishop, St. Robert/Abbot, St.
|
||||
Catherine of Siena/Virgin) — never doubled.
|
||||
- The "sometimes 1, sometimes 2" appearance wasn't actually the same
|
||||
category flipping — it was different *ranks of saint* getting
|
||||
different octave privileges. Only martyr-class saints (already in the
|
||||
dedicated-chain group above) are ever eligible for even a
|
||||
commemoration inside a true privileged octave (confirmed: St.
|
||||
Anicetus, commemorated on a Semiduplex day inside the actual Easter
|
||||
octave, correctly showing the doubled dedicated-chain text).
|
||||
Confessor/Abbot/Virgin-class saints never get that far — checked St.
|
||||
Leo I and St. Basil the Great both landing on/near a real octave in
|
||||
some year, and both are fully transferred, no commemoration at all —
|
||||
so the plain-suffix categories structurally can never be observed
|
||||
inside a real octave, only in the ordinary weeks around it.
|
||||
- One practical check this settled without needing a code change:
|
||||
`common-of-pope-martyrs` (Ss. Soter & Caius, Ss. Cletus & Marcellinus)
|
||||
is itself one of the dedicated-chain categories, so its one stored
|
||||
file is technically the Paschaltide-only text, not a universal
|
||||
default — but tracing both saints' fixed dates against every possible
|
||||
Easter date confirms this is harmless: whenever their date would fall
|
||||
outside Eastertide, it lands inside the Sacred Triduum instead
|
||||
(always fully transferred, never rendered), so the stored text is
|
||||
correct for every real occurrence.
|
||||
|
||||
Built: `hours/resolve-common.ts`'s `appendPaschaltideAlleluia`/
|
||||
`withPaschaltideAlleluia`, applied to the Common-category fallback
|
||||
branch of `getBenedictusAntiphon` and `resolveMinorHourAntiphon` (not
|
||||
the `-paschaltide`-file branch, which already had its own correct
|
||||
wholesale text; not a saint's own unique `${propers}-antiphon`, which
|
||||
would need its own live-requery to confirm one way or the other rather
|
||||
than assuming the Common-category finding transfers; not capitula,
|
||||
whose closing versicles get the same suffix in the reference engine too
|
||||
— confirmed, St. Robert's Terce capitulum — but whose text is one
|
||||
embedded R./V. blob per language rather than a clean appendable tail,
|
||||
not attempted this pass). Guards against double-appending on a category
|
||||
whose stored text already ends in "allelúja" (`common-of-pope-martyrs`,
|
||||
per above). 4 new tests in `tests/hours/resolve-common.test.ts`
|
||||
(`Paschaltide alleluia suffix on Common-category antiphons`), 300 tests
|
||||
passing.
|
||||
|
||||
Verified end-to-end: a runtime smoke test (folded into
|
||||
`tests/hours/resolve-common.test.ts`) covering the same-person reuse
|
||||
|
||||
+103
-19
@@ -132,19 +132,95 @@ export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Tempore Paschali" — the reference engine's own window for a Common's
|
||||
* Paschaltide variant (Commune/C1p.txt etc.: only Apostles, a Martyr-
|
||||
* Bishop, and Several Martyrs get one — Confessors/Virgins/Doctors never
|
||||
* do, matching the real tradition's own restriction of Alleluia/victory
|
||||
* imagery to apostles and martyrs specifically). Deliberately the same
|
||||
* three-season set as Compline's own Marian-antiphon table
|
||||
* (`data/hours/marian-antiphon-by-season.yml`'s `eastertide`/
|
||||
* `ascensiontide`/`pentecost` all mapping to Regina Caeli, not just
|
||||
* `eastertide` alone) — "Paschalis" runs through the end of the Pentecost
|
||||
* octave in both cases, not just to Ascension.
|
||||
* "Tempore Paschali" — the reference engine's own window for Paschaltide
|
||||
* text changes. Deliberately the same three-season set as Compline's own
|
||||
* Marian-antiphon table (`data/hours/marian-antiphon-by-season.yml`'s
|
||||
* `eastertide`/`ascensiontide`/`pentecost` all mapping to Regina Caeli, not
|
||||
* just `eastertide` alone) — "Paschalis" runs through the end of the
|
||||
* Pentecost octave in both cases, not just to Ascension.
|
||||
*
|
||||
* RESOLVED (2026-08): the earlier version of this comment claimed only
|
||||
* Apostles/Martyr-Bishop/Several-Martyrs get a Paschaltide change at all,
|
||||
* and that Confessors/Virgins/Doctors never do. Live-querying settled
|
||||
* this more precisely, per the "St. Athanasius/allelúja" investigation
|
||||
* (TODO.md): the reference source (Commune/C1p.txt, C2p.txt, C2bp.txt,
|
||||
* C3p.txt, C3bp.txt) really does give *some* categories — Apostles,
|
||||
* Martyr, Martyr-Bishop, Several Martyrs, Pope-Martyrs — their own
|
||||
* wholesale-different Paschaltide text (often, not always, doubling the
|
||||
* alleluia as genuinely different content, confirmed by reading the raw
|
||||
* chain rather than guessing from the rendered page). But categories
|
||||
* *without* one of those (Confessor-Bishop/Doctor, Abbot, Virgin — all
|
||||
* three live-verified this pass: St. Athanasius, St. Robert, St.
|
||||
* Catherine of Siena) still change during this same window: the engine
|
||||
* mechanically appends a single ", allelúja"/", alleluia" to the closing
|
||||
* clause of the otherwise-unchanged base text. `appendPaschaltideAlleluia`
|
||||
* below models that fallback; the dedicated-chain categories are still
|
||||
* handled the older way, by trying a same-id `-paschaltide` file first
|
||||
* (only authored for `common-of-apostles` so far — the other four
|
||||
* dedicated-chain categories fall through to the mechanical suffix too
|
||||
* until someone authors their real wholesale text, which is a strictly
|
||||
* better approximation than rendering the bare non-Paschaltide default).
|
||||
*/
|
||||
const PASCHALTIDE_SEASONS = new Set(['eastertide', 'ascensiontide', 'pentecost']);
|
||||
|
||||
/**
|
||||
* The reference engine's mechanical Paschaltide fallback for a Common
|
||||
* antiphon with no dedicated wholesale-different text of its own: a
|
||||
* single ", allelúja."/", alleluia." tacked onto the closing clause,
|
||||
* text otherwise byte-identical to the non-Paschaltide default. Live-
|
||||
* verified across three unrelated categories (St. Athanasius/Confessor-
|
||||
* Bishop, St. Robert/Abbot, St. Catherine of Siena/Virgin) — never
|
||||
* doubled, and the app's stored antiphon text already carries the
|
||||
* incipit/full split as one string with an embedded "*"
|
||||
* (`splitNamedAntiphon`/`splitAntiphon`), so appending to the very end
|
||||
* only ever lands in the "full" half, matching the reference engine's own
|
||||
* behavior exactly (its *incipit*-only rendering, sung before the psalm,
|
||||
* never carries the suffix — only the closing, full-text rendering after
|
||||
* it does).
|
||||
*
|
||||
* Skips a language whose stored text already ends in "allelúja"/
|
||||
* "alleluia" — `common-of-pope-martyrs` is the one category this
|
||||
* actually matters for: its own stored files were, unavoidably, captured
|
||||
* from a live query already inside Paschaltide (its only two members'
|
||||
* fixed dates structurally can never be queried at any other real point
|
||||
* in the calendar without landing in the Sacred Triduum instead, where
|
||||
* they're always fully transferred and never render at all — see
|
||||
* TODO.md) — so what's stored there already *is* the Paschaltide text,
|
||||
* and blindly appending here would double up.
|
||||
*
|
||||
* Deliberately scoped to antiphons only, not capitula: a capitulum's own
|
||||
* closing versicles get the same suffix in the reference engine too
|
||||
* (confirmed, St. Robert's Terce capitulum), but that text is one
|
||||
* embedded R./V. blob per language, not a clean appendable tail, and
|
||||
* reproducing that safely wasn't attempted this pass — see TODO.md.
|
||||
*/
|
||||
function appendPaschaltideAlleluia(resolved: ResolvedText): ResolvedText {
|
||||
const suffixByLang: Partial<Record<string, string>> = { la: 'allelúja', en: 'alleluia' };
|
||||
const text: Partial<Record<string, string>> = { ...resolved.text };
|
||||
for (const [lang, suffix] of Object.entries(suffixByLang)) {
|
||||
const t = text[lang];
|
||||
if (!t || /allel(?:úja|uia)\.?\s*$/i.test(t)) {
|
||||
continue;
|
||||
}
|
||||
text[lang] = `${t.replace(/\.\s*$/, '')}, ${suffix}.`;
|
||||
}
|
||||
return { ...resolved, text };
|
||||
}
|
||||
|
||||
/** Applies appendPaschaltideAlleluia only when the day actually falls in
|
||||
* Paschaltide and there's real text to append it to — the shared guard
|
||||
* both resolveMinorHourAntiphon's Common fallback and getBenedictusAntiphon
|
||||
* use, so neither has to repeat the two checks inline. */
|
||||
function withPaschaltideAlleluia(resolved: ResolvedText, day: LiturgicalDay): ResolvedText {
|
||||
if (!PASCHALTIDE_SEASONS.has(day.season)) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved.status.la === 'missing' && resolved.status.en === 'missing') {
|
||||
return resolved;
|
||||
}
|
||||
return appendPaschaltideAlleluia(resolved);
|
||||
}
|
||||
|
||||
/** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by
|
||||
* a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when
|
||||
* authored, then by that saint's shared Common (`${hourId}-antiphon-
|
||||
@@ -155,7 +231,9 @@ const PASCHALTIDE_SEASONS = new Set(['eastertide', 'ascensiontide', 'pentecost']
|
||||
* been authored — same honest "not gated behind whether content exists,
|
||||
* just eligible to override at all" fallback as every other override in
|
||||
* this codebase: an eligible feast with none of these authored yet just
|
||||
* falls through to the plain weekday default silently.
|
||||
* falls through to the plain weekday default silently. The plain (non-
|
||||
* `-paschaltide`) Common fallback itself still picks up a seasonal change
|
||||
* in Paschaltide — see `withPaschaltideAlleluia`.
|
||||
*/
|
||||
export function resolveMinorHourAntiphon(
|
||||
hourId: string,
|
||||
@@ -178,7 +256,7 @@ export function resolveMinorHourAntiphon(
|
||||
}
|
||||
const common = resolveCommon(`${hourId}-antiphon-${commonId}`);
|
||||
if (common.status.la !== 'missing' || common.status.en !== 'missing') {
|
||||
return common;
|
||||
return withPaschaltideAlleluia(common, day);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,12 +418,18 @@ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
|
||||
* antiphon` (not authored yet for any temporal id — resolves "missing",
|
||||
* same pending convention as everywhere else) for a temporal one, then
|
||||
* `saint.benedictusCommon` (`benedictus-antiphon-${benedictusCommon}.yml`,
|
||||
* see SaintRecord's own doc comment) when neither exists. Uses
|
||||
* resolveOfficeWinner, not the raw `day.winner` — on an octave day this
|
||||
* is the octave's own feast's antiphon (live-verified: St. Lawrence's
|
||||
* "In cratícula", not the commemorated St. Clare's, and not the plain
|
||||
* temporal day's), not a *commemorated* saint's, which is a separate,
|
||||
* weaker standing (see getDayCollects/sanctoralCommemorationPart).
|
||||
* see SaintRecord's own doc comment) when neither exists — run through
|
||||
* `withPaschaltideAlleluia` on the way out, so a Common-category
|
||||
* Benedictus antiphon picks up the same seasonal suffix its Prime/Terce/
|
||||
* Sext/None counterparts do (see PASCHALTIDE_SEASONS's own doc comment);
|
||||
* a saint's own unique `${propers}-antiphon` deliberately isn't — that
|
||||
* would need its own live-requery to confirm one way or the other, not
|
||||
* assumed from the Common-category finding. Uses resolveOfficeWinner, not
|
||||
* the raw `day.winner` — on an octave day this is the octave's own
|
||||
* feast's antiphon (live-verified: St. Lawrence's "In cratícula", not the
|
||||
* commemorated St. Clare's, and not the plain temporal day's), not a
|
||||
* *commemorated* saint's, which is a separate, weaker standing (see
|
||||
* getDayCollects/sanctoralCommemorationPart).
|
||||
*/
|
||||
export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
|
||||
const winner = resolveOfficeWinner(day);
|
||||
@@ -360,7 +444,7 @@ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
|
||||
}
|
||||
}
|
||||
if (saint?.benedictusCommon) {
|
||||
return resolveCommon(`benedictus-antiphon-${saint.benedictusCommon}`);
|
||||
return withPaschaltideAlleluia(resolveCommon(`benedictus-antiphon-${saint.benedictusCommon}`), day);
|
||||
}
|
||||
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
resolveMinorHourChapter,
|
||||
splitNamedAntiphon,
|
||||
} from '../../src/hours/resolve-common';
|
||||
import type { LiturgicalDay } from '../../src/calendar/types';
|
||||
import type { LiturgicalDay, Season } from '../../src/calendar/types';
|
||||
|
||||
function syntheticSanctoralDay(id: string): LiturgicalDay {
|
||||
function syntheticSanctoralDay(id: string, season: Season = 'trinitytide'): LiturgicalDay {
|
||||
return {
|
||||
date: '2026-01-01',
|
||||
weekday: 'thursday',
|
||||
season: 'trinitytide',
|
||||
season,
|
||||
temporalCategory: 'ordinary-feria',
|
||||
winner: { kind: 'sanctoral', id, name: id, rank: 'simplex' },
|
||||
commemorations: [],
|
||||
@@ -256,3 +256,52 @@ describe('splitNamedAntiphon', () => {
|
||||
expect(repeat?.kind === 'antiphon' ? repeat.text.status.en : undefined).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Paschaltide alleluia suffix on Common-category antiphons (2026-08)', () => {
|
||||
// Live-query-verified (St. Athanasius/Confessor-Bishop, St. Robert/Abbot,
|
||||
// St. Catherine of Siena/Virgin): a Common category with no dedicated
|
||||
// wholesale-different Paschaltide text still gets a mechanical single
|
||||
// ", allelúja"/", alleluia" appended to its otherwise-unchanged base text
|
||||
// during Eastertide/Ascensiontide/Pentecost-octave-week. St. Ambrose
|
||||
// (common-of-a-confessor-bishop, propers: null) exercises the Common
|
||||
// fallback branch directly rather than a per-saint proper file.
|
||||
it("appends the seasonal alleluia to a Common Benedictus antiphon in Paschaltide", () => {
|
||||
const eastertide = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'eastertide'));
|
||||
expect(eastertide.text.la).toBe(
|
||||
'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus, allelúja.',
|
||||
);
|
||||
expect(eastertide.text.en).toBe(
|
||||
'Well done, thou good and faithful servant * thou hast been faithful over a few things, I will make thee ruler over many things, saith the Lord, alleluia.',
|
||||
);
|
||||
expect(eastertide.status).toEqual({ la: 'verified', en: 'verified' });
|
||||
});
|
||||
|
||||
it('leaves the same Common Benedictus antiphon unchanged outside Paschaltide', () => {
|
||||
const ordinary = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'trinitytide'));
|
||||
expect(ordinary.text.la).toBe(
|
||||
'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus.',
|
||||
);
|
||||
expect(ordinary.text.la).not.toContain('allelúja');
|
||||
});
|
||||
|
||||
it('appends the suffix on the minor-hour Common fallback too (ascensiontide, not just eastertide)', () => {
|
||||
const prime = resolveMinorHourAntiphon('prime', syntheticSanctoralDay('st-ambrose', 'ascensiontide'), {});
|
||||
expect(prime.text.la?.endsWith('allelúja.')).toBe(true);
|
||||
expect(prime.text.la).not.toMatch(/allelúja.*allelúja/); // never doubled for this category
|
||||
});
|
||||
|
||||
it("doesn't double up on a Common category whose stored text is already the (unavoidably Paschaltide-only) native text", () => {
|
||||
// common-of-pope-martyrs (Ss. Soter and Caius) -- its own stored files
|
||||
// were necessarily captured from a live query already inside
|
||||
// Paschaltide (see TODO.md: their fixed dates can only ever land in
|
||||
// real Eastertide or the Sacred Triduum, never elsewhere), so the
|
||||
// text already ends "allelúja, allelúja." -- appending again would be
|
||||
// wrong.
|
||||
const day = syntheticSanctoralDay('ss-soter-and-caius', 'eastertide');
|
||||
const benedictus = getBenedictusAntiphon(day);
|
||||
expect(benedictus.text.la).toBe(
|
||||
'Fíliæ Jerúsalem, * veníte et vidéte Mártyres cum corónis, quibus coronávit eos Dóminus in die solemnitátis et lætítiæ, allelúja, allelúja.',
|
||||
);
|
||||
expect(benedictus.text.la?.match(/allelúja/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user