Build the Vespers ordo: mechanism, weekday content, seasonal content

Wires resolveOrdo('vespers', ...) end to end: opening versicle,
weekday psalmody (4 groups, handling the joined Ps 115+116 and the
split Ps 138/143 via a PsalmRef-like shape), the chapter/responsory/
hymn/versicle office bundle, Magnificat, day-collects — reusing
resolveEveningDay so First/Second Vespers precedence and the cross-day
commemoration (both added in the previous commit) apply automatically
— and a closing that reuses Lauds' own conclusio, since no distinct
"Vespera" version of that exact text turned up in the source.

getMagnificatAntiphon mirrors getBenedictusAntiphon's saint/temporal-id
lookup order, but also falls back to real content Benedictus has no
equivalent of: the classic ferial set that quotes the Magnificat's own
text in sequence across the week, with the correct Monastic-rubric
variant picked for Tue/Thu/Fri/Sat (two of those needed a fresh
translation where the Monastic Latin diverges from the given English
in content, not just wording).

Also includes Vespers' own seasonal content (Advent/Lent/Passiontide/
Paschaltide chapter+responsory+hymn+versicle), transcribed from the
same source blocks as the weekday content, with the Monastic-specific
hymn variants hand-applied from the source's substitution notation
against each base Roman text (one transcription slip caught and fixed
mid-pass: the Passiontide hymn's closing lines had been copied from
the Roman text instead of the Monastic one actually being
transcribed). Passiontide's own responsory/hymn correctly omit the
Gloria Patri, matching real rubric and the source data as given.

All weekday/seasonal content marked `draft`, not `verified` — read
directly from the reference source, not cross-checked against a live
rendering per day the way the psalmody was.

Six other hours' resolvePart switches needed the three new HourPart
kinds ('vespers-psalmody', 'vespers-office', 'magnificat') added to
their exhaustive not-used lists so they still typecheck; three
pre-existing tests assumed Vespers was still "coming soon" and are
updated to exercise Matins (still the genuine stub) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:14:19 -04:00
parent 979bbe6aa4
commit de83786793
45 changed files with 1649 additions and 12 deletions
+75 -1
View File
@@ -1,10 +1,23 @@
import type { ResolvedPart, ResolvedText } from './types';
import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types';
import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calendar/types';
import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave } from '../calendar';
import { splitAntiphon } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
type BilingualText = Partial<Record<string, string>>;
type TranslationStatus = 'verified' | 'draft' | 'missing';
interface MagnificatWeekdayDefault {
antiphon: BilingualText;
status?: Partial<Record<string, TranslationStatus>>;
}
// Monday-Saturday only — Sunday has no fixed weekday default (its own
// antiphon varies by Proper of the Time; see the data file's own header).
const vespersMagnificatAntiphons = vespersMagnificatAntiphonsData as Partial<
Record<Weekday, MagnificatWeekdayDefault>
>;
function toResolvedText(proper: ProperText): ResolvedText {
return { text: proper.text, status: proper.status, citation: proper.citation };
@@ -163,6 +176,25 @@ export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined {
*/
const PASCHALTIDE_SEASONS = new Set(['eastertide', 'ascensiontide', 'pentecost']);
/**
* Which seasonal office-content suffix (if any) a day's season maps to —
* shared by Lauds' and Vespers' own resolveOffice, both of which fall
* back to the plain weekday default when this is undefined (and both
* check a per-feast/octave override first, ahead of this). Paschaltide
* covers eastertide/ascensiontide/pentecost uniformly, matching the
* source's own single "Pasch" block for both hours — no separate
* Ascension/Pentecost-specific chapter/hymn exists there.
*/
export function seasonalOfficeSuffix(season: string): string | undefined {
if (season === 'advent' || season === 'lent' || season === 'passiontide') {
return season;
}
if (PASCHALTIDE_SEASONS.has(season)) {
return 'paschaltide';
}
return undefined;
}
/**
* The reference engine's mechanical Paschaltide fallback for a Common
* antiphon with no dedicated wholesale-different text of its own: a
@@ -449,6 +481,48 @@ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
return { text: {}, status: { la: 'missing', en: 'missing' } };
}
/**
* Vespers' Magnificat antiphon — mirrors getBenedictusAntiphon's lookup
* order exactly (temporal id, then a saint's own `${propers}-magnificat-
* antiphon`, then their Common category's `magnificat-antiphon-${common}`
* — none of that content authored yet, a separate future pass the same
* shape as the Benedictus one was), but with one real difference: a bare
* ferial weekday (Mon-Sat) has actual content to fall back to —
* data/hours/vespers-magnificat-antiphons.yml's classic "quote the
* Magnificat's own text in sequence" set — which Benedictus has no
* equivalent of. `withPaschaltideAlleluia` applied to that fallback too,
* by analogy with the Common-category treatment above; not independently
* confirmed for this specific case.
*/
export function getMagnificatAntiphon(day: LiturgicalDay): ResolvedText {
const winner = resolveOfficeWinner(day);
if (winner.kind === 'temporal') {
const named = toResolvedText(getTemporalProper(`${winner.id}-magnificat-antiphon`));
if (named.status.la !== 'missing' || named.status.en !== 'missing') {
return named;
}
const weekdayDefault = vespersMagnificatAntiphons[day.weekday];
if (weekdayDefault) {
const resolved = weekdayDefault.status
? { text: weekdayDefault.antiphon, status: weekdayDefault.status }
: verifiedText(weekdayDefault.antiphon);
return withPaschaltideAlleluia(resolved, day);
}
return named;
}
const saint = getSaintRecord(winner.id);
if (saint?.propers) {
const proper = resolveCommon(`${saint.propers}-magnificat-antiphon`);
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
}
if (saint?.benedictusCommon) {
return withPaschaltideAlleluia(resolveCommon(`magnificat-antiphon-${saint.benedictusCommon}`), day);
}
return { text: {}, status: { la: 'missing', en: 'missing' } };
}
export function verifiedText(text: Partial<Record<string, string>>): ResolvedText {
const status: Partial<Record<string, 'verified'>> = {};
for (const lang of Object.keys(text)) {