Opening versicle; Ps 66 (fixed, no antiphon at all); the weekday- variable psalm groups (Ps 50 plus 1-2 more, framed by their own antiphon each, wholesale-substituted by Sunday's Ps 50+117-share-one- antiphon shape); the weekday OT canticle; the Laudate psalms (148-150) under one more shared antiphon; the weekday chapter/responsory/hymn/ versicle bundle; the Benedictus (framed by a day-resolved antiphon, same incipit-or-full/full-repeat shape as Compline's Nunc Dimittis); the Kyrie/Pater-noster short litany; the day's collect(s) — winner plus one per commemoration, via the new getDayCollects; the four Tridentine suffrages (omitted on a Double-or-higher feast); the conclusio; the closing versicle (the silent Our Father that precedes it isn't re-rendered, its text having already appeared once at the short litany); and the closing Marian antiphon, reusing Compline's own mechanism exactly. Known, deliberate gap, not yet fixed: the psalmody doesn't model the Commune/feast override real practice applies on a Semiduplex-or-higher day (substituting an entirely different psalm+canticle set) — every day currently gets the plain ferial weekday distribution regardless of who wins. That's a separate, larger propers-authoring task (a full Common-of-Saints psalm set per Common). Also a simplification in getDayCollects: each collect in a multi- collect day renders as its own complete "Let us pray ... Amen." block, rather than the more compressed real form (one "Let us pray," only the last collect's doxology). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Ordo verified against Divinum Officium (Monastic Tridentinum 1617), Lauds
|
||||
# live query, 2026-08-11 — see src/hours/lauds.ts for how each part
|
||||
# resolves, and data/hours/lauds-antiphons.yml / lauds-canticles/*.yml /
|
||||
# propers/common/lauds-*.yml for the underlying weekday content.
|
||||
#
|
||||
# The suffrages (Holy Cross, BVM, Ss. Peter & Paul, Peace) are gated by
|
||||
# omitOnDouble — see hours/types.ts's 'suffrages' doc comment for the
|
||||
# caveat on what else real practice also suppresses them for that isn't
|
||||
# modeled yet.
|
||||
id: lauds
|
||||
parts:
|
||||
- kind: opening-versicle
|
||||
- kind: lauds-psalmody
|
||||
- kind: lauds-office
|
||||
- kind: benedictus
|
||||
- kind: preces
|
||||
textRef: { source: common, id: lauds-short-litany }
|
||||
- kind: day-collects
|
||||
- kind: suffrages
|
||||
omitOnDouble: true
|
||||
- kind: preces
|
||||
textRef: { source: common, id: lauds-conclusio }
|
||||
- kind: versicle
|
||||
textRef: { source: common, id: lauds-closing-versicle }
|
||||
- kind: marian-antiphon
|
||||
@@ -86,6 +86,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'by-day-kind':
|
||||
case 'variable':
|
||||
case 'day-collect':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'day-collects':
|
||||
throw new Error(`Compline's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-5
@@ -1,7 +1,192 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||
import type { LiturgicalDay, Weekday } from '../calendar/types';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { getDayLabel } from '../calendar/day-label';
|
||||
import { getPsalmVerses } from '../psalter';
|
||||
import { getCanticle } from './lauds-canticles';
|
||||
import { getOpeningVersicleId } from './opening-versicle';
|
||||
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
|
||||
import { isDoubleOrHigher } from './antiphon';
|
||||
import { resolveCommon, getDayCollects, getBenedictusAntiphon, splitNamedAntiphon } from './resolve-common';
|
||||
import laudsDefinitionData from '../data/hours/lauds.yml';
|
||||
import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml';
|
||||
|
||||
// Milestone 4 — first hour needing real sanctoral occurrence + rank/commemoration
|
||||
// resolution (calendar/feasts.ts, calendar/easter.ts). Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'lauds', date, parts: [], notImplemented: true };
|
||||
const laudsDefinition = laudsDefinitionData as HourDefinition;
|
||||
|
||||
type BilingualText = Partial<Record<string, string>>;
|
||||
interface LaudsGroup {
|
||||
psalms: number[];
|
||||
antiphon: BilingualText;
|
||||
}
|
||||
interface LaudsWeekdayAntiphons {
|
||||
groups: LaudsGroup[];
|
||||
canticle: { id: string; antiphon: BilingualText };
|
||||
laudate: { antiphon: BilingualText };
|
||||
}
|
||||
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, LaudsWeekdayAntiphons>;
|
||||
|
||||
// Mon-Fri share one capitulum verbatim (confirmed against the live engine
|
||||
// — see lauds-capitulum-ferial.yml); Sunday and Saturday each have their
|
||||
// own.
|
||||
function capitulumId(weekday: Weekday): string {
|
||||
if (weekday === 'sunday') {
|
||||
return 'lauds-capitulum-sunday';
|
||||
}
|
||||
if (weekday === 'saturday') {
|
||||
return 'lauds-capitulum-saturday';
|
||||
}
|
||||
return 'lauds-capitulum-ferial';
|
||||
}
|
||||
|
||||
function psalmParts(numbers: number[], antiphon: BilingualText, opening: ResolvedText): ResolvedPart[] {
|
||||
const parts: ResolvedPart[] = numbers.map((number, i) => ({
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: number,
|
||||
antiphon: i === 0 ? opening : undefined,
|
||||
verses: getPsalmVerses(number).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
||||
}));
|
||||
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(antiphon).full });
|
||||
return parts;
|
||||
}
|
||||
|
||||
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
|
||||
|
||||
/** Joins a canticle's verse array into one flowing text block, the same
|
||||
* shape nunc-dimittis/benedictus already use — Lauds' weekday canticles
|
||||
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's
|
||||
* how they were transcribed, not because callers need per-verse access.
|
||||
* Appends the fixed Gloria Patri line every canticle ends with in real
|
||||
* practice, same wording as benedictus.yml's own trailing line. */
|
||||
function canticleText(canticleId: string): ResolvedText {
|
||||
const canticle = getCanticle(canticleId);
|
||||
const text: Partial<Record<string, string>> = {};
|
||||
const status: Partial<Record<string, 'verified' | 'draft' | 'missing'>> = {};
|
||||
const GLORIA = {
|
||||
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
|
||||
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
|
||||
};
|
||||
for (const lang of ['la', 'en'] as const) {
|
||||
const lines = canticle.verses.map((v) => v.text[lang]).filter((t): t is string => Boolean(t));
|
||||
if (lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
text[lang] = `${lines.join('\n')}\n${GLORIA[lang]}`;
|
||||
let worst: 'verified' | 'draft' | 'missing' = 'verified';
|
||||
for (const v of canticle.verses) {
|
||||
const s = v.status[lang] ?? 'missing';
|
||||
if (STATUS_RANK[s] > STATUS_RANK[worst]) {
|
||||
worst = s;
|
||||
}
|
||||
}
|
||||
status[lang] = worst;
|
||||
}
|
||||
return { text, status };
|
||||
}
|
||||
|
||||
/** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody'
|
||||
* doc comment for the scope and the known Commune-override gap. */
|
||||
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
|
||||
const wd = laudsAntiphons[day.weekday];
|
||||
const opening = (antiphon: BilingualText) => {
|
||||
const { incipit, full } = splitNamedAntiphon(antiphon);
|
||||
return isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
};
|
||||
const parts: ResolvedPart[] = [
|
||||
{
|
||||
kind: 'psalm',
|
||||
psalmNumber: 66,
|
||||
verses: getPsalmVerses(66).map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
||||
},
|
||||
];
|
||||
for (const group of wd.groups) {
|
||||
parts.push(...psalmParts(group.psalms, group.antiphon, opening(group.antiphon)));
|
||||
}
|
||||
const canticle = getCanticle(wd.canticle.id);
|
||||
parts.push({
|
||||
kind: 'canticle',
|
||||
canticleId: canticle.id,
|
||||
antiphon: opening(wd.canticle.antiphon),
|
||||
text: canticleText(canticle.id),
|
||||
});
|
||||
parts.push({ kind: 'antiphon', text: splitNamedAntiphon(wd.canticle.antiphon).full });
|
||||
parts.push(...psalmParts([148, 149, 150], wd.laudate.antiphon, opening(wd.laudate.antiphon)));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function resolveOffice(weekday: Weekday): ResolvedPart[] {
|
||||
return [
|
||||
{ kind: 'chapter', text: resolveCommon(capitulumId(weekday)) },
|
||||
{ kind: 'responsory', text: resolveCommon(`lauds-responsory-${weekday}`) },
|
||||
{ kind: 'hymn', text: resolveCommon(`lauds-hymn-${weekday}`) },
|
||||
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${weekday}`) },
|
||||
];
|
||||
}
|
||||
|
||||
const SUFFRAGES = ['lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-apostles', 'lauds-suffrage-peace'];
|
||||
const SUFFRAGE_LABELS: Record<string, string> = {
|
||||
'lauds-suffrage-cross': 'Of the Holy Cross',
|
||||
'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary',
|
||||
'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul',
|
||||
'lauds-suffrage-peace': 'For Peace',
|
||||
};
|
||||
|
||||
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
switch (part.kind) {
|
||||
case 'opening-versicle':
|
||||
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
|
||||
case 'lauds-psalmody':
|
||||
return resolvePsalmody(day);
|
||||
case 'lauds-office':
|
||||
return resolveOffice(day.weekday);
|
||||
case 'benedictus': {
|
||||
const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day).text);
|
||||
const opening = isDoubleOrHigher(day.winner) ? full : incipit;
|
||||
return [
|
||||
{ kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening },
|
||||
{ kind: 'antiphon', text: full },
|
||||
];
|
||||
}
|
||||
case 'preces':
|
||||
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
|
||||
case 'day-collects':
|
||||
return getDayCollects(day).map((text) => ({ kind: 'prayer' as const, text }));
|
||||
case 'suffrages':
|
||||
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
|
||||
return [];
|
||||
}
|
||||
return SUFFRAGES.map((id) => ({
|
||||
kind: 'preces' as const,
|
||||
text: resolveCommon(id),
|
||||
label: SUFFRAGE_LABELS[id],
|
||||
}));
|
||||
case 'versicle':
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
|
||||
case 'marian-antiphon': {
|
||||
const id = getMarianAntiphonId(day);
|
||||
return [{ kind: 'preces', text: resolveCommon(id), label: getMarianAntiphonLabel(id) }];
|
||||
}
|
||||
// Not used by Lauds.
|
||||
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':
|
||||
throw new Error(`Lauds' ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
const day = resolveDay(date);
|
||||
const parts = laudsDefinition.parts.flatMap((part) => resolvePart(part, day));
|
||||
return { hourId: 'lauds', date, parts, dayLabel: getDayLabel(day) };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'closing-antiphon':
|
||||
case 'nunc-dimittis':
|
||||
case 'marian-antiphon':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'day-collects':
|
||||
throw new Error(`None's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
|
||||
case 'nunc-dimittis':
|
||||
case 'marian-antiphon':
|
||||
case 'day-collect':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'day-collects':
|
||||
throw new Error(`Prime's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,62 @@ export function getDayCollect(day: LiturgicalDay): ResolvedText {
|
||||
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's own collect, plus one more per commemoration (calendar/
|
||||
* types.ts's LiturgicalDay.commemorations) — Lauds/Vespers say all of
|
||||
* these in sequence, unlike the Little Hours' single getDayCollect.
|
||||
*
|
||||
* Simplification, not yet corrected: real practice compresses this into
|
||||
* one "Orémus" and lets only the *last* collect close with the full
|
||||
* doxology, with earlier ones trailing straight into the next ("And:").
|
||||
* Each collect file (data/propers/temporal/*-collect.yml, the per-saint
|
||||
* *-collect.yml files) already bakes in its own "Orémus."/"Per Dóminum...
|
||||
* Amen." for the single-collect case every other hour uses today, and
|
||||
* stripping that back out per-collect to chain them properly would need
|
||||
* text surgery this doesn't attempt — so on a commemorated day, each
|
||||
* collect here renders as its own complete, separate block instead. An
|
||||
* octave commemoration never contributes a collect (none authored — the
|
||||
* octave content pull only ever sourced Matins readings, see propers/
|
||||
* octave-readings.ts), so those are silently skipped, same as a
|
||||
* not-yet-authored saint's own collect resolves to "missing" rather than
|
||||
* throwing.
|
||||
*/
|
||||
export function getDayCollects(day: LiturgicalDay): ResolvedText[] {
|
||||
const collects = [getDayCollect(day)];
|
||||
for (const commemoration of day.commemorations) {
|
||||
if (commemoration.kind === 'temporal') {
|
||||
collects.push(toResolvedText(getTemporalProper(`${commemoration.id}-collect`)));
|
||||
} else if (commemoration.kind === 'sanctoral') {
|
||||
const saint = getSaintRecord(commemoration.id);
|
||||
collects.push(
|
||||
saint?.propers
|
||||
? resolveCommon(`${saint.propers}-collect`)
|
||||
: { text: {}, status: { la: 'missing', en: 'missing' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
return collects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a
|
||||
* per-saint `${propers}-antiphon` (already authored during the sanctoral
|
||||
* pull, sourced from each saint's own raw [Ant 1] — see e.g.
|
||||
* st-lawrence-antiphon.yml) for a sanctoral winner, `${id}-benedictus-
|
||||
* antiphon` (not authored yet for any temporal id — resolves "missing",
|
||||
* same pending convention as everywhere else) for a temporal one.
|
||||
*/
|
||||
export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText {
|
||||
if (day.winner.kind === 'temporal') {
|
||||
return toResolvedText(getTemporalProper(`${day.winner.id}-benedictus-antiphon`));
|
||||
}
|
||||
const saint = getSaintRecord(day.winner.id);
|
||||
if (saint?.propers) {
|
||||
return resolveCommon(`${saint.propers}-antiphon`);
|
||||
}
|
||||
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)) {
|
||||
|
||||
@@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'closing-antiphon':
|
||||
case 'nunc-dimittis':
|
||||
case 'marian-antiphon':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'day-collects':
|
||||
throw new Error(`Sext's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
|
||||
case 'closing-antiphon':
|
||||
case 'nunc-dimittis':
|
||||
case 'marian-antiphon':
|
||||
case 'lauds-psalmody':
|
||||
case 'lauds-office':
|
||||
case 'benedictus':
|
||||
case 'suffrages':
|
||||
case 'day-collects':
|
||||
throw new Error(`Terce's ordo doesn't support a '${part.kind}' part`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,48 @@ export type HourPart =
|
||||
// to a 'canticle' ResolvedPart (with the opening antiphon inline) plus a
|
||||
// standalone 'antiphon' ResolvedPart for the closing repeat.
|
||||
| { kind: 'nunc-dimittis' }
|
||||
// Lauds only. Everything from Ps 66 through the Laudate psalms (148-150)
|
||||
// inclusive, resolved by weekday — see hours/lauds.ts's resolvePsalmody
|
||||
// and data/hours/lauds-antiphons.yml. Opaque here on purpose: the real
|
||||
// shape (a fixed Ps 66 with no antiphon at all, then a weekday-variable
|
||||
// number of antiphoned psalm groups, then a weekday-variable OT canticle,
|
||||
// then the Laudate psalms under one more shared antiphon) doesn't fit any
|
||||
// existing part kind, and hardcoding psalm numbers in lauds.yml can't
|
||||
// work since they differ by weekday. Known, deliberate gap: does NOT
|
||||
// model the Commune/feast override of this whole block (real practice
|
||||
// substitutes an entirely different set of psalms+antiphons+canticle on
|
||||
// a Semiduplex-or-higher day) — every day currently gets the plain
|
||||
// ferial weekday distribution regardless of who wins. Fixing that is a
|
||||
// separate, larger propers-authoring task (a full Common-of-Saints psalm
|
||||
// set per Common), not started yet.
|
||||
| { kind: 'lauds-psalmody' }
|
||||
// Lauds only. The weekday-resolved chapter/short-responsory/hymn/closing
|
||||
// versicle bundle that follows the psalmody — see hours/lauds.ts and
|
||||
// data/propers/common/lauds-{capitulum,responsory,hymn,versicle}-*.yml.
|
||||
// Expands into 4 ResolvedParts (chapter, responsory, hymn, versicle).
|
||||
| { kind: 'lauds-office' }
|
||||
// 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/
|
||||
// full-after-Creed... no Creed at Lauds, just before+after framing shape
|
||||
// as nunc-dimittis, gated the same way by isDoubleOrHigher.
|
||||
| { kind: 'benedictus' }
|
||||
// Lauds only. The four fixed Tridentine suffrages (Holy Cross, BVM,
|
||||
// Ss. Peter & Paul, Peace) said after the day's collect(s) — see
|
||||
// data/propers/common/lauds-suffrage-*.yml. `omitOnDouble` gates the
|
||||
// whole set at once (real practice: suffrages drop on a Double-or-higher
|
||||
// feast) — an honest simplification, see hours/lauds.ts for the caveat
|
||||
// on what real practice also suppresses them for that isn't modeled.
|
||||
| { kind: 'suffrages'; omitOnDouble?: true }
|
||||
// Lauds/Vespers. The day's own collect *and* one more per commemoration
|
||||
// (calendar/types.ts's LiturgicalDay.commemorations) — see hours/
|
||||
// resolve-common.ts's getDayCollects. Distinct from the singular
|
||||
// 'day-collect' the Little Hours use (which only ever renders the
|
||||
// winner's own collect, never a commemoration's). Simplification: each
|
||||
// collect renders as its own complete "Let us pray ... Amen." block
|
||||
// rather than the more compressed real form (one "Let us pray," only the
|
||||
// last collect closes with the full doxology) — see hours/lauds.ts.
|
||||
| { kind: 'day-collects' }
|
||||
// Compline's closing Marian antiphon (with its own versicle and collect
|
||||
// baked into the same text block) — resolved by season, defaulting to
|
||||
// Salve Regina. See hours/marian-antiphon.ts.
|
||||
|
||||
Reference in New Issue
Block a user