Prime: real content (psalms, Martyrology, Regula) and ordo corrections
Deploy / deploy (push) Failing after 21s

Pulls in verified content from Divinum Officium rather than placeholders:
17 psalms (2, 6, 7-19, 118, 129), 365 days of the Martyrology, and the full
121-reading Regula cycle, plus the Athanasian Creed.

Ordo corrections driven by review against the real engine output:
- Capitulum and Preces now pick a Sunday/feast vs. ferial form
  (calendar/isSundayOrFeast); ferial Preces said every ferial day by choice.
- Chapter responsory, hymn doxology, and the opening versicle's
  Alleluia/Laus tibi all vary by season via a shared resolver
  (hours/seasonal-propers.ts).
- Real Roman Kalends/Nones/Ides Latin dating (calendar/roman-date.ts,
  verified against 363/365 real Martyrology headings) plus the historical
  "bis sextus" Feb 29 handling, and the Martyrology's Luna (moon-day)
  heading (a ported Golden-Number calculation).
- Fixed responsory structure (was missing its initial full repeat),
  weekday psalm antiphons (opening as incipit-or-full by rank, full
  repeat after the psalms/Creed as its own part, "*" chant mark kept),
  scripture citations on the capitulum/lectio brevis, and V./R. markers
  switched from Unicode symbols to plain text for reliable font rendering.
- Dropped Pretiosa and the dead-commemoration psalm (129) for time;
  trimmed section headings down to the ones that are actually named
  things (Preces, Chapter Office, etc. no longer relabel connective text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 20:03:53 -04:00
parent a5dc6502d7
commit 58958aa847
571 changed files with 21842 additions and 72 deletions
+78
View File
@@ -0,0 +1,78 @@
import type { MartyrologyEntry } from './types';
import { lunaDay, lunaOrdinalLatin, lunaOrdinalEnglish } from './luna';
import { romanDateLatin } from '../calendar/roman-date';
// One file per calendar day (MM-DD.yml) — only a handful exist so far
// (content-authoring the full 366-day Roman Martyrology is a separate,
// later task, same as full psalm-text authoring). Missing days resolve as
// pending rather than throwing.
const modules = import.meta.glob<{ default: MartyrologyEntry }>('../data/martyrology/*.yml', {
eager: true,
});
const entries = new Map<string, MartyrologyEntry>();
for (const mod of Object.values(modules)) {
entries.set(mod.default.monthDay, mod.default);
}
const ENGLISH_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
function dayFollowing(isoDate: string): { month: number; day: number; year: number; monthDay: string } {
const date = new Date(`${isoDate}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + 1);
const month = date.getUTCMonth() + 1;
const day = date.getUTCDate();
const year = date.getUTCFullYear();
return { month, day, year, monthDay: `${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` };
}
/**
* The Latin heading uses the real Roman Kalends/Nones/Ides date (see
* calendar/roman-date.ts); English dates are given in plain Gregorian
* form — there's no traditional English equivalent of the Kalends system.
*/
function heading(month: number, day: number, year: number): { la: string; en: string } {
const luna = lunaDay(month, day, year);
return {
la: `${romanDateLatin(month, day, year)} Luna ${lunaOrdinalLatin(luna)}. Anno Dómini ${year}.`,
en: `${ENGLISH_MONTHS[month - 1]} ${day}, ${year} — the ${lunaOrdinalEnglish(luna)} day of the Moon.`,
};
}
/**
* Monastic Prime reads *tomorrow's* Martyrology entry (the announcement of
* the next day's saints), not today's — this computes that offset so callers
* just pass the ordo's own date.
*/
export function getMartyrologyEntryFor(isoDate: string): MartyrologyEntry {
const { month, day, year, monthDay } = dayFollowing(isoDate);
const entry = entries.get(monthDay);
if (!entry) {
return { monthDay, text: {}, status: { la: 'missing', en: 'missing' } };
}
const head = heading(month, day, year);
return {
monthDay,
text: {
la: entry.text.la ? `${head.la}\n\n${entry.text.la}` : entry.text.la,
en: entry.text.en ? `${head.en}\n\n${entry.text.en}` : entry.text.en,
},
status: entry.status,
};
}
export type { MartyrologyEntry } from './types';
+130
View File
@@ -0,0 +1,130 @@
const MONTHSUP = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function dateToYdays(day: number, month: number, year: number): number {
const monthOffset = MONTHSUP[month - 1] ?? 0;
return monthOffset + day + (month > 2 && isLeapYear(year) ? 1 : 0);
}
function lunaTable(yday: number, letter: string): number {
const letters = 'abcdefghiklmnpqrstuABCDERFGHMNP';
const letterPosition = letters.indexOf(letter) + 1;
const cycleDay = yday % 59;
const m = yday < 36 ? 30 : ((yday - 35) % 59 || 59) < 29 ? 29 : 30;
let i = cycleDay < 36 ? letterPosition : letterPosition - 1;
if (cycleDay < 36) {
if (letterPosition > 25) i -= 1;
if (letterPosition === 25 && cycleDay === 35) i += 1;
} else if (letterPosition > 25) {
i -= 2;
}
if (yday > 58) {
if (letterPosition > 25 && cycleDay < 5) i -= 1;
if (letterPosition === 26 && cycleDay === 5) i -= 1;
}
return (((i - 1 + cycleDay) % m) + m) % m + 1;
}
/**
* The traditional Martyrology's "Luna N" (day of the moon) — ported from
* Divinum Officium's _luna_day/_luna_table (a Golden-Number/epact-based
* lunar calendar algorithm, not a real astronomical calculation). Verified
* against 3 known engine outputs: 2026-08-26 -> 13, 2026-12-25 -> 16,
* 2027-03-02 -> 24.
*/
export function lunaDay(month: number, day: number, year: number): number {
let lettersForAurea: string;
if (year < 1700) {
lettersForAurea = 'amDdqGgtNkBbnEerHhu';
} else if (year < 1900) {
lettersForAurea = 'PlCcpFfsMiAamDdqGgt';
} else if (year < 2200) {
lettersForAurea = 'NkBbnEerHhuPlCcpRfs';
} else {
lettersForAurea = 'MiAamDdqGgtNkBbnEer';
}
const aureaNumber = (year % 19) + 1;
const letter = lettersForAurea[aureaNumber - 1];
if (!letter) {
throw new Error(`unreachable: no aurea letter for year ${year}`);
}
let yday = dateToYdays(day, month, year);
if (isLeapYear(year) && (month > 2 || (month === 2 && day > 23))) {
yday -= 1;
}
let luna = lunaTable(yday, letter);
if (aureaNumber === 1 && month === 1 && letter !== 'P' && day + lunaTable(1, letter) < 32) {
luna -= 1;
}
return luna;
}
const LATIN_ORDINALS = [
'prima',
'secúnda',
'tértia',
'quarta',
'quinta',
'sexta',
'séptima',
'octáva',
'nona',
'décima',
'undécima',
'duodécima',
'tértia décima',
'quarta décima',
'quinta décima',
'sexta décima',
'décima séptima',
'duodevicésima',
'undevicésima',
'vicésima',
'vicésima prima',
'vicésima secúnda',
'vicésima tértia',
'vicésima quarta',
'vicésima quinta',
'vicésima sexta',
'vicésima séptima',
'vicésima octáva',
'vicésima nona',
'tricésima',
];
export function lunaOrdinalLatin(n: number): string {
const ordinal = LATIN_ORDINALS[n - 1];
if (!ordinal) {
throw new Error(`luna day out of range: ${n}`);
}
return ordinal;
}
function englishOrdinalSuffix(n: number): string {
if (n > 3 && n < 21) {
return 'th';
}
switch (n % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
export function lunaOrdinalEnglish(n: number): string {
return `${n}${englishOrdinalSuffix(n)}`;
}
+9
View File
@@ -0,0 +1,9 @@
import type { LanguageCode, TranslationStatus } from '../psalter/types';
/** Keyed by MM-DD — the martyrology repeats every civil year, same as the
* sanctoral calendar, and for the same reason (see calendar/types.ts). */
export interface MartyrologyEntry {
monthDay: string;
text: Partial<Record<LanguageCode, string>>;
status: Partial<Record<LanguageCode, TranslationStatus>>;
}