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:
@@ -91,6 +91,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'day-collects':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`Compline's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'variable':
|
||||
case 'day-collect':
|
||||
case 'nunc-dimittis':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`Lauds' ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'day-collects':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`None's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'day-collects':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -67,6 +67,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'day-collects':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`Sext's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'day-collects':
|
||||
case 'vespers-psalmody':
|
||||
case 'vespers-office':
|
||||
case 'magnificat':
|
||||
throw new Error(`Terce's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,29 @@ export type HourPart =
|
||||
// data/propers/common/lauds-{capitulum,responsory,hymn,versicle}-*.yml.
|
||||
// Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle).
|
||||
| { kind: 'lauds-office' }
|
||||
// Vespers only. The weekday-resolved 4-psalm-group psalmody — see
|
||||
// hours/vespers.ts and data/hours/vespers-antiphons.yml. Opaque for the
|
||||
// same reason as 'lauds-psalmody': each group's antiphon covers either
|
||||
// one whole psalm, two whole psalms said together under one shared
|
||||
// antiphon (Ps 115+116, Monday), or two verse-ranges of one psalm split
|
||||
// under one shared antiphon (Ps 138/143, Thu/Fri) — no existing part
|
||||
// kind represents that. No per-feast override mechanism yet (unlike
|
||||
// Lauds' getPsalmodyOverrideFor) — every day renders the plain weekday
|
||||
// default for now.
|
||||
| { kind: 'vespers-psalmody' }
|
||||
// Vespers only. The weekday-resolved chapter/short-responsory/hymn/
|
||||
// versicle bundle that follows the psalmody — see hours/vespers.ts and
|
||||
// data/propers/common/vespers-{capitulum,responsory,hymn,versicle}-*.yml.
|
||||
// Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle).
|
||||
| { kind: 'vespers-office' }
|
||||
// Vespers only. Magnificat (Luke 1:46-55), fixed text, framed by a
|
||||
// day-resolved antiphon — see hours/resolve-common.ts's
|
||||
// getMagnificatAntiphon, which mirrors getBenedictusAntiphon's
|
||||
// saint/temporal-id lookup but also falls back to a real weekday
|
||||
// default (data/hours/vespers-magnificat-antiphons.yml, Mon-Sat) that
|
||||
// Benedictus has no equivalent of. Same incipit-or-full/full-after
|
||||
// framing as benedictus/nunc-dimittis, gated by isDoubleOrHigher.
|
||||
| { kind: 'magnificat' }
|
||||
// Lauds only. Benedictus (Luke 1:68-79), fixed text (see propers/common/
|
||||
// benedictus.yml) framed by a day-resolved antiphon (see hours/
|
||||
// resolve-common.ts's getBenedictusAntiphon) — same incipit-or-full/
|
||||
|
||||
+165
-5
@@ -1,7 +1,167 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types';
|
||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||
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 {
|
||||
resolveCommon,
|
||||
getDayCollects,
|
||||
getMagnificatAntiphon,
|
||||
splitNamedAntiphon,
|
||||
resolveOfficeWinner,
|
||||
verifiedText,
|
||||
seasonalOfficeSuffix,
|
||||
} from './resolve-common';
|
||||
import vespersDefinitionData from '../data/hours/vespers.yml';
|
||||
import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml';
|
||||
|
||||
// Milestone 4, alongside Lauds — also has the first/second-Vespers overlap
|
||||
// wrinkle (see OccurringFeast.vespersFrom). Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'vespers', date, parts: [], notImplemented: true };
|
||||
const vespersDefinition = vespersDefinitionData as HourDefinition;
|
||||
|
||||
type BilingualText = Partial<Record<string, string>>;
|
||||
/** A whole psalm, or a `{number, verses}` verse-range slice of one — same
|
||||
* shape as psalter/distribution.ts's PsalmRef, needed here (unlike Lauds'
|
||||
* plain `psalms: number[]`) because two of the week's groups split a
|
||||
* single psalm into two ranges under one shared antiphon — see
|
||||
* vespers-antiphons.yml's own header. */
|
||||
type PsalmRefLike = number | { number: number; verses?: string };
|
||||
interface VespersGroup {
|
||||
psalms: PsalmRefLike[];
|
||||
antiphon: BilingualText;
|
||||
}
|
||||
interface VespersDay {
|
||||
groups: VespersGroup[];
|
||||
}
|
||||
const vespersAntiphons = vespersAntiphonsData as Record<Weekday, VespersDay>;
|
||||
|
||||
function normalizePsalmRef(ref: PsalmRefLike): { number: number; verses?: string } {
|
||||
return typeof ref === 'number' ? { number: ref } : ref;
|
||||
}
|
||||
|
||||
/** One group's worth of psalm(s) under one shared antiphon — the antiphon
|
||||
* opens (incipit-or-full) before the first psalm/verse-range and repeats
|
||||
* in full once, after the last. Mirrors lauds.ts's psalmParts, extended
|
||||
* for verse-range entries (see PsalmRefLike). */
|
||||
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 })),
|
||||
}));
|
||||
parts.push({ kind: 'antiphon', text: full });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
|
||||
* the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4
|
||||
* groups in sequence. No per-feast override mechanism yet — see
|
||||
* hours/types.ts's 'vespers-psalmody' doc comment. */
|
||||
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
const wd = vespersAntiphons[day.weekday];
|
||||
return wd.groups.flatMap((group) => psalmParts(group, day));
|
||||
}
|
||||
|
||||
function vespersCapitulumId(weekday: Weekday): string {
|
||||
return weekday === 'saturday' ? 'vespers-capitulum-saturday' : 'vespers-capitulum-sunday-ferial';
|
||||
}
|
||||
|
||||
function vespersResponsoryId(weekday: Weekday): string {
|
||||
if (weekday === 'sunday') return 'vespers-responsory-sunday';
|
||||
if (weekday === 'saturday') return 'vespers-responsory-saturday';
|
||||
return 'vespers-responsory-ferial';
|
||||
}
|
||||
|
||||
function vespersVersicleId(weekday: Weekday): string {
|
||||
return weekday === 'saturday' ? 'vespers-versicle-saturday' : 'vespers-versicle-sunday-ferial';
|
||||
}
|
||||
|
||||
/** The chapter/responsory/hymn/versicle bundle following the psalmody —
|
||||
* see hours/types.ts's 'vespers-office' doc comment. No per-feast
|
||||
* override mechanism yet, unlike Lauds' resolveOffice. Falls to a
|
||||
* *seasonal* default first (Advent/Lent/Passiontide/Paschaltide — see
|
||||
* resolve-common.ts's seasonalOfficeSuffix), then the plain weekday
|
||||
* default. */
|
||||
function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
|
||||
const seasonSuffix = seasonalOfficeSuffix(day.season);
|
||||
const key = seasonSuffix ?? day.weekday;
|
||||
return [
|
||||
{
|
||||
kind: 'chapter',
|
||||
text: resolveCommon(seasonSuffix ? `vespers-capitulum-${key}` : vespersCapitulumId(day.weekday)),
|
||||
},
|
||||
{
|
||||
kind: 'responsory',
|
||||
text: resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)),
|
||||
},
|
||||
{ kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) },
|
||||
{
|
||||
kind: 'versicle',
|
||||
text: resolveCommon(seasonSuffix ? `vespers-versicle-${key}` : vespersVersicleId(day.weekday)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
switch (part.kind) {
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||
case 'vespers-psalmody':
|
||||
return resolvePsalmody(day);
|
||||
case 'vespers-office':
|
||||
return resolveOffice(day);
|
||||
case 'magnificat': {
|
||||
const { incipit, full } = splitNamedAntiphon(getMagnificatAntiphon(day));
|
||||
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
|
||||
return [
|
||||
{ kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening },
|
||||
{ kind: 'antiphon', text: full },
|
||||
];
|
||||
}
|
||||
case 'day-collects':
|
||||
return getDayCollects(day);
|
||||
case 'preces':
|
||||
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
case 'versicle':
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
||||
// Not used by Vespers.
|
||||
case 'hymn':
|
||||
case 'lesson':
|
||||
case 'psalm':
|
||||
case 'canticle':
|
||||
case 'martyrology':
|
||||
case 'rule-reading':
|
||||
case 'creed':
|
||||
case 'closing-antiphon':
|
||||
case 'by-day-kind':
|
||||
case 'variable':
|
||||
case 'day-collect':
|
||||
case 'nunc-dimittis':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'lauds-preces':
|
||||
case 'marian-antiphon':
|
||||
throw new Error(`Vespers' ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
// Every part below is an evening-hour concern, same reasoning as
|
||||
// Compline — resolves off whichever day's identity actually governs
|
||||
// tonight, including the cross-day commemoration folded into
|
||||
// day.commemorations by resolveEveningDay itself (see calendar/
|
||||
// vespers.ts) — getDayCollects picks that up for free, no special
|
||||
// handling needed here.
|
||||
const day = resolveEveningDay(date);
|
||||
const parts = vespersDefinition.parts.flatMap((part) => resolvePart(part, day));
|
||||
return { hourId: 'vespers', date, parts, dayLabel: getDayLabel(day) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user