Label Matins nocturns and the Invitatory in the rendered ordo
Deploy / deploy (push) Successful in 1m17s
Deploy / deploy (push) Successful in 1m17s
Adds a section-heading ResolvedPart kind and emits "Invitatory" and "Nocturn I/II/III" headings in matins.ts, so the hour's structural boundaries are visible instead of rendering as one undifferentiated stream of parts. Ferial (1-nocturn) days get no Nocturn heading, matching their existing single-unnumbered-nocturn design. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,8 @@ interface MatinsSundayAntiphons {
|
|||||||
}
|
}
|
||||||
const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons;
|
const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons;
|
||||||
|
|
||||||
|
const NOCTURN_NUMERAL = ['I', 'II', 'III'] as const;
|
||||||
|
|
||||||
type PsalmPart = Extract<ResolvedPart, { kind: 'psalm' }>;
|
type PsalmPart = Extract<ResolvedPart, { kind: 'psalm' }>;
|
||||||
|
|
||||||
function plainPsalm(number: number): PsalmPart {
|
function plainPsalm(number: number): PsalmPart {
|
||||||
@@ -302,15 +304,19 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
const parts: ResolvedPart[] = [
|
const parts: ResolvedPart[] = [
|
||||||
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
|
||||||
plainPsalm(3),
|
plainPsalm(3),
|
||||||
|
{ kind: 'section-heading', label: 'Invitatory' },
|
||||||
...invitatoryParts(day),
|
...invitatoryParts(day),
|
||||||
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
{ kind: 'hymn', text: resolveCommon('matins-hymn-ferial') },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (threeNocturns && day.weekday === 'sunday') {
|
if (threeNocturns && day.weekday === 'sunday') {
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day));
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day));
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day));
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
|
||||||
@@ -324,18 +330,24 @@ export function resolveOrdo(date: string): ResolvedOrdo {
|
|||||||
// three-tier picture).
|
// three-tier picture).
|
||||||
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
|
||||||
if (override) {
|
if (override) {
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
parts.push(...sundayPsalmNocturn(override.nocturn1, day));
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
parts.push(...sundayPsalmNocturn(override.nocturn2, day));
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
parts.push(...sundayCanticleNocturn(override.nocturn3, day));
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
} else {
|
} else {
|
||||||
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day);
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` });
|
||||||
parts.push(...n1);
|
parts.push(...n1);
|
||||||
parts.push(...(nocturn1Readings ?? []));
|
parts.push(...(nocturn1Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` });
|
||||||
parts.push(...n2);
|
parts.push(...n2);
|
||||||
parts.push(...(nocturn2Readings ?? []));
|
parts.push(...(nocturn2Readings ?? []));
|
||||||
|
parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` });
|
||||||
parts.push(...n3);
|
parts.push(...n3);
|
||||||
parts.push(...(nocturn3Readings ?? []));
|
parts.push(...(nocturn3Readings ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export type ResolvedPart =
|
|||||||
// have a single unnumbered nocturn and don't use this wrapper at all —
|
// have a single unnumbered nocturn and don't use this wrapper at all —
|
||||||
// see hours/matins.ts). `nocturn` is 1-3.
|
// see hours/matins.ts). `nocturn` is 1-3.
|
||||||
| { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] }
|
| { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] }
|
||||||
|
// A standalone structural heading with no content of its own — marks the
|
||||||
|
// start of a larger named section that doesn't correspond to one specific
|
||||||
|
// part kind (Matins' "Invitatory", "Nocturn I/II/III"). Distinct from the
|
||||||
|
// per-part `<h3 class="ordo-part-label">` every other kind already uses.
|
||||||
|
| { kind: 'section-heading'; label: string }
|
||||||
// Matins only. Sung after the last lesson on a 3-nocturn day (Sunday or
|
// Matins only. Sung after the last lesson on a 3-nocturn day (Sunday or
|
||||||
// a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a
|
// a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a
|
||||||
// plain ferial (1-nocturn) day.
|
// plain ferial (1-nocturn) day.
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
|
|||||||
${part.psalms.map((p) => renderPart(p, languages)).join('')}
|
${part.psalms.map((p) => renderPart(p, languages)).join('')}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
|
case 'section-heading':
|
||||||
|
return `<h2 class="section-heading">${escapeHtml(part.label)}</h2>`;
|
||||||
case 'te-deum':
|
case 'te-deum':
|
||||||
return `
|
return `
|
||||||
<section class="ordo-part ordo-part-te-deum">
|
<section class="ordo-part ordo-part-te-deum">
|
||||||
|
|||||||
@@ -275,6 +275,15 @@ button:focus-visible {
|
|||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-brass);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
margin: var(--space-4) 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.ordo-part-citation {
|
.ordo-part-citation {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
|
|||||||
it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => {
|
it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => {
|
||||||
expect(ordo.parts[0]?.kind).toBe('versicle');
|
expect(ordo.parts[0]?.kind).toBe('versicle');
|
||||||
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
|
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
|
||||||
expect(ordo.parts[2]).toMatchObject({ kind: 'psalm', psalmNumber: 94 });
|
expect(ordo.parts[2]).toMatchObject({ kind: 'section-heading', label: 'Invitatory' });
|
||||||
const invitatoryAntiphon = (ordo.parts[2] as { antiphon?: { text: Record<string, string> } }).antiphon;
|
expect(ordo.parts[3]).toMatchObject({ kind: 'psalm', psalmNumber: 94 });
|
||||||
|
const invitatoryAntiphon = (ordo.parts[3] as { antiphon?: { text: Record<string, string> } }).antiphon;
|
||||||
expect(invitatoryAntiphon?.text.la).toContain('Veníte');
|
expect(invitatoryAntiphon?.text.la).toContain('Veníte');
|
||||||
// Real practice repeats the invitatory antiphon between verse groups —
|
// Real practice repeats the invitatory antiphon between verse groups —
|
||||||
// deliberately not modeled that way here (see hours/matins.ts's own
|
// deliberately not modeled that way here (see hours/matins.ts's own
|
||||||
|
|||||||
Reference in New Issue
Block a user