Compare commits

..

6 Commits

Author SHA1 Message Date
will 949196b847 Author proper Lauds/Vespers antiphons for the Beheading of St. John Baptist
Deploy / deploy (push) Successful in 1m32s
Live-verified against Divinum Officium (Tridentine 1906/1910 and
Divino Afflatu 1954, Sancti/08-29.txt) — the feast's own 5-antiphon
set ([Ant Vespera], reused verbatim at Lauds as [Ant Laudes]), now
wired through both the newly rank-agnostic Lauds/Vespers proper tier
and the new minor-hour derivation mechanism. Vespers gets only the
first four (mapped onto vu's Monastic-1617-structured four-psalm
Vespers, matching common-of-a-martyr.yml's own 109/111/112/115
baseline) — the reference's own fifth antiphon is a secular five-
psalm-Vespers detail that doesn't apply here, and is used only at
Lauds' Laudate psalm instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:35:33 -04:00
will 74ddd61aee Derive Prime/Terce/Sext/None antiphons from a saint's own Lauds set
resolveMinorHourAntiphon fell straight from a (rarely-authored)
dedicated per-hour proper file to the saint's generic Common-category
antiphon, skipping the real proper text a saint may already have as
its 5-antiphon Lauds/Vespers set. The reference engine's own
getanthoras() mechanism derives exactly these four minor-hour
antiphons from that same 5-antiphon set by fixed index (Prime/Terce/
Sext/None <- antiphons 1/2/3/5) whenever no dedicated per-hour proper
exists; vu had no equivalent, so any minor hour fell back to the
generic Common antiphon even when the feast's own text was already
sitting right there in its Lauds override.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:35:22 -04:00
will f3cf30c1c7 Stop gating Lauds/Vespers proper psalmody antiphons behind rank
getPsalmodyOverrideFor bundled "which psalm numbers are said" and
"which antiphons are used" into one lookup, both gated behind the same
duplex-majus+ rank threshold. A saint's own authored proper antiphon
text should never be gated behind rank — only the weaker, generic
Common-category substitute has any business being rank-gated. Splits
the lookup into two independent tiers: the feast's own proper (any
rank, via the new getPsalmodyProperOverrideId) tried first, then the
existing duplex-majus+ Common-category fallback — mirroring the split
getMinorHourOverrideId already made for the chapter/responsory/hymn/
versicle bundle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:34:48 -04:00
will b163f502b8 Scope renderCitation to the currently displayed languages
renderCitation deduplicated a text's citation values across every
stored language, not just the ones actually being rendered. Once a
citation's La/En abbreviations genuinely differ (e.g. "Jac 1:12" vs.
"Jas 1:12", or "Sap. 10:10" vs. "Wis. 10:10"), both survived the
dedupe and got joined together regardless of view mode, leaking the
other language's abbreviation into a single-language display.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:33:48 -04:00
will 7ab517cbe3 Fall back to a saint's collectCommon/collectName in commemorations too
sanctoralCommemorationPart() only ever checked a commemorated saint's
own propers, so a saint like St. Sabina — no propers of her own, but
a real collectCommon + collectName template fallback, verified text
and all — rendered as an outright "missing" commemoration. The day's
primary-collect resolver (getDayCollect) already had this same
collectCommon/collectName fallback tier; this brings the
commemoration path in line with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:33:30 -04:00
will a3a9d568ed Include the canticle's own citation in Lauds' resolved output
canticleText() built the resolved text/status from the weekday
canticle's verses but never copied the source YAML's own citation
field across, so every Lauds canticle rendered with no book/chapter/
verse citation at all despite the data already carrying one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFGHb4XMe6Wya4pGpPhEEi
2026-08-29 07:33:15 -04:00
6 changed files with 212 additions and 46 deletions
@@ -0,0 +1,27 @@
# Verified against Divinum Officium (Tridentine 1906/1910 and Divino
# Afflatu 1954, `[Ant Vespera]`), Latin/Sancti/08-29.txt +
# English/Sancti/08-29.txt, 2026-08-29 — Lauds reuses the Vespers set
# verbatim (`[Ant Laudes]` -> `@:Ant Vespera`).
id: decollation-of-st-john-baptist
groups:
- psalms: [92]
antiphon:
la: "Heródes enim ténuit * et ligávit Joánnem et pósuit in cárcerem propter Herodíadem."
en: "For Herod had laid hold upon John, * and bound him, and put him in prison, for Herodias' sake."
- psalms: [99]
antiphon:
la: "Dómine mi rex, * da mihi in disco caput Joánnis Baptístæ."
en: "O Lord my King, * give me in a charger the head of John the Baptist."
- psalms: [62]
antiphon:
la: "Puéllæ saltánti * imperávit mater: Nihil áliud petas, nisi caput Joánnis."
en: "The damsel danced, * and her mother charged her, saying: See thou ask nothing, but only the head of John."
canticle:
id: canticum-trium-puerorum
antiphon:
la: "Arguébat * Heródem Joánnes propter Herodíadem, quam túlerat fratri suo Philíppo uxórem."
en: "John had rebuked Herod for Herodias' sake, * his brother Philip's wife, for he had taken her."
laudate:
antiphon:
la: "Da mihi in disco * caput Joánnis Baptístæ. Et contristátus est rex propter jusjurándum."
en: "Give me in a charger the head of John the Baptist. * And the King was sorry, for his oath's sake."
@@ -0,0 +1,29 @@
# Verified against Divinum Officium (Tridentine 1906/1910 and Divino
# Afflatu 1954, `[Ant Vespera]`), Latin/Sancti/08-29.txt +
# English/Sancti/08-29.txt, 2026-08-29. The reference's own `[Rule]`
# ("Psalm5 Vespera=116") is a secular five-psalm-Vespers detail that
# doesn't apply to vu's Monastic-1617-structured (four-psalm) Vespers —
# same 109/111/112/115 baseline as common-of-a-martyr.yml's own Vespers
# psalmody, with this feast's own first four proper antiphons (of its
# five, `[Ant Vespera]` 1-4) substituted in; the fifth ("Da mihi in
# disco...") has no corresponding slot here and is used only at Lauds'
# Laudate psalm instead (see lauds-psalmody-overrides/decollation-of-st-
# john-baptist.yml).
id: decollation-of-st-john-baptist
groups:
- psalms: [109]
antiphon:
la: "Heródes enim ténuit * et ligávit Joánnem et pósuit in cárcerem propter Herodíadem."
en: "For Herod had laid hold upon John, * and bound him, and put him in prison, for Herodias' sake."
- psalms: [111]
antiphon:
la: "Dómine mi rex, * da mihi in disco caput Joánnis Baptístæ."
en: "O Lord my King, * give me in a charger the head of John the Baptist."
- psalms: [112]
antiphon:
la: "Puéllæ saltánti * imperávit mater: Nihil áliud petas, nisi caput Joánnis."
en: "The damsel danced, * and her mother charged her, saying: See thou ask nothing, but only the head of John."
- psalms: [115]
antiphon:
la: "Arguébat * Heródem Joánnes propter Herodíadem, quam túlerat fratri suo Philíppo uxórem."
en: "John had rebuked Herod for Herodias' sake, * his brother Philip's wife, for he had taken her."
+33 -16
View File
@@ -4,7 +4,7 @@ import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter';
import { getCanticle } from './lauds-canticles';
import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
import { getLaudsSaintOverride, getLaudsCommonOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { applyFlexaMark } from './antiphon';
@@ -19,6 +19,7 @@ import {
seasonalOfficeSuffix,
isFerialOrVigil,
getOfficeOverrideId,
getPsalmodyProperOverrideId,
resolveResponsory,
resolveOfficeBundle,
resolveSuffrages,
@@ -49,24 +50,40 @@ interface PsalmodyBlock {
const laudsAntiphons = laudsAntiphonsData as Record<Weekday, PsalmodyBlock>;
/**
* A duplex-majus+ saint's own proper psalmody (per-feast, not per-Common —
* explicit choice), or one of the named temporal feasts eligible via
* resolve-common.ts's getOfficeOverrideId. Falls back to `undefined` — the
* plain weekday default — for an eligible feast with no override authored
* yet, same honest incremental-content convention as everywhere else in
* this codebase; it's not gated behind whether content exists, just
* behind whether it's *eligible* to override at all.
* Two independent tiers, tried in order:
*
* getOfficeOverrideId itself checks resolveOfficeWinner(day), not the raw
* `day.winner` — on an octave day this is the octave's own feast (e.g. St.
* Lawrence, days 2-8 of his own octave), which is exactly what should
* supply the psalmody override there too, live-verified alongside the
* collect/Benedictus-antiphon fix (see resolve-common.ts's
* 1. **Proper** — this feast's own authored antiphons (per-feast, not
* per-Common — explicit choice), eligible at *any rank* via
* resolve-common.ts's getPsalmodyProperOverrideId. A saint's own real
* proper text is never gated behind rank — see that function's own
* doc comment for why this used to be wrongly bundled with tier 2's
* gate (fixed 2026-08-29).
* 2. **Common category** — a generic-by-category substitute, still
* gated at duplex-majus+ via getOfficeOverrideId (a real, distinct,
* weaker tier: same conventional psalm numbers, not this feast's own
* text).
*
* Falls back to `undefined` — the plain weekday default — for an eligible
* feast with no override authored yet in either tier, same honest
* incremental-content convention as everywhere else in this codebase;
* it's not gated behind whether content exists, just behind whether it's
* *eligible* to override at all.
*
* Both id-eligibility functions check resolveOfficeWinner(day), not the
* raw `day.winner` — on an octave day this is the octave's own feast
* (e.g. St. Lawrence, days 2-8 of his own octave), which is exactly what
* should supply the psalmody override there too, live-verified alongside
* the collect/Benedictus-antiphon fix (see resolve-common.ts's
* resolveOfficeWinner doc comment).
*/
function getPsalmodyOverrideFor(day: LiturgicalDay): LaudsPsalmodyOverride | undefined {
const id = getOfficeOverrideId(day);
return id ? getLaudsPsalmodyOverride(id) : undefined;
const properId = getPsalmodyProperOverrideId(day);
const proper = properId ? getLaudsSaintOverride(properId) : undefined;
if (proper) {
return proper;
}
const categoryId = getOfficeOverrideId(day);
return categoryId ? getLaudsCommonOverride(categoryId) : undefined;
}
// Mon-Fri share one capitulum verbatim (confirmed against the live engine
@@ -128,7 +145,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
}
status[lang] = worst;
}
return { text, status };
return { text, status, citation: { la: canticle.citation, en: canticle.citation } };
}
/** Ps 66 through the Laudate psalms — see hours/types.ts's 'lauds-psalmody'
+84 -9
View File
@@ -7,6 +7,7 @@ import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, is
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon, isDoubleOrHigher } from './antiphon';
import { getLaudsSaintOverride } from './lauds-psalmody-overrides';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
type BilingualText = Partial<Record<string, string>>;
@@ -180,6 +181,34 @@ export function getOfficeOverrideId(day: LiturgicalDay): string | undefined {
return undefined;
}
/**
* The id to look up for today's winner's own *proper* Lauds/Vespers
* psalmody antiphons (`hours/lauds.ts`'s and `hours/vespers.ts`'s own
* `getPsalmodyOverrideFor`) — eligible at **any rank**, unlike
* `getOfficeOverrideId` above. A saint's own authored proper antiphon
* text isn't something rank should ever gate — rank only has standing to
* gate the weaker, generic Common-category substitute (still looked up
* via `getOfficeOverrideId`'s duplex-majus+ threshold, a real distinct
* tier: same conventional psalm numbers, a generic-by-category antiphon,
* not this feast's own text). Mirrors `getMinorHourOverrideId`'s identical
* "any sanctoral winner is eligible, it's the caller that decides whether
* anything's actually authored for the id" reasoning — the psalmody
* override table previously reused `getOfficeOverrideId`'s single
* duplex-majus+ gate for both its proper and Common tiers at once, which
* silently discarded a proper antiphon whenever it happened to be
* authored for a sub-duplex-majus feast (fixed 2026-08-29).
*/
export function getPsalmodyProperOverrideId(day: LiturgicalDay): string | undefined {
const winner = resolveOfficeWinner(day);
if (winner.kind === 'temporal' && ALWAYS_OVERRIDE_TEMPORAL_IDS.has(winner.id)) {
return winner.id;
}
if (winner.kind === 'sanctoral') {
return winner.id;
}
return undefined;
}
/**
* The id to look up for today's office winner's (resolveOfficeWinner)
* minor-hour content (antiphon + chapter) — `${hourId}-antiphon-${id}`/
@@ -328,19 +357,52 @@ function withPaschaltideAlleluia(resolved: ResolvedText, day: LiturgicalDay): Re
return appendPaschaltideAlleluia(resolved);
}
// The reference engine's own `getanthoras()` (specials.pl): when a saint
// has no dedicated per-hour minor-hour antiphon of its own, one is
// derived from that saint's 5-antiphon Lauds/Vespers set (`[Ant Laudes]`,
// which for most saints is `@:Ant Vespera`) by fixed index — Prime takes
// the first (the same antiphon as the first psalm-group), Terce/Sext the
// second/third, None the fifth (the Laudate antiphon); the fourth
// (canticle) antiphon is never used for a minor hour. vu's own
// `LaudsPsalmodyOverride` already stores that same 5-antiphon set in that
// exact order (`groups[0..2]`, `canticle`, `laudate`), so once a saint's
// Lauds proper is authored, no separate per-hour file is needed for this
// tier — same "mechanism first, content incrementally" pattern as
// everywhere else in this codebase.
const MINOR_HOUR_LAUDS_ANTIPHON_INDEX: Record<string, number> = { prime: 0, terce: 1, sext: 2, none: 4 };
function deriveMinorHourAntiphonFromLauds(hourId: string, overrideId: string): ResolvedText | undefined {
const index = MINOR_HOUR_LAUDS_ANTIPHON_INDEX[hourId];
if (index === undefined) {
return undefined;
}
const lauds = getLaudsSaintOverride(overrideId);
if (!lauds) {
return undefined;
}
const antiphon = index === 4 ? lauds.laudate.antiphon : lauds.groups[index]?.antiphon;
if (!antiphon) {
return undefined;
}
return verifiedText(antiphon);
}
/** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by
* a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when
* authored, then by that saint's shared Common (`${hourId}-antiphon-
* authored, then by that saint's own Lauds/Vespers antiphon set (see
* `deriveMinorHourAntiphonFromLauds` above — a real liturgical derivation,
* not a guess), then by that saint's shared Common (`${hourId}-antiphon-
* ${minorHoursCommon}`, see SaintRecord's own doc comment) — tried first
* in its Paschaltide-variant form (`${minorHoursCommon}-paschaltide`)
* when the day falls in Paschaltide and that variant has been authored,
* per PASCHALTIDE_SEASONS's own doc comment — when a proper one hasn't
* been authored — same honest "not gated behind whether content exists,
* just eligible to override at all" fallback as every other override in
* this codebase: an eligible feast with none of these authored yet just
* falls through to the plain weekday default silently. The plain (non-
* `-paschaltide`) Common fallback itself still picks up a seasonal change
* in Paschaltide — see `withPaschaltideAlleluia`.
* per PASCHALTIDE_SEASONS's own doc comment — when neither of the first
* two has been authored — same honest "not gated behind whether content
* exists, just eligible to override at all" fallback as every other
* override in this codebase: an eligible feast with none of these
* authored yet just falls through to the plain weekday default silently.
* The plain (non-`-paschaltide`) Common fallback itself still picks up a
* seasonal change in Paschaltide — see `withPaschaltideAlleluia`. (The
* derived-from-Lauds tier does not get that treatment — see TODO.md.)
*/
export function resolveMinorHourAntiphon(
hourId: string,
@@ -353,6 +415,10 @@ export function resolveMinorHourAntiphon(
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
const derived = deriveMinorHourAntiphonFromLauds(hourId, overrideId);
if (derived) {
return derived;
}
const commonId = getSaintRecord(overrideId)?.minorHoursCommon;
if (commonId) {
if (PASCHALTIDE_SEASONS.has(day.season)) {
@@ -542,7 +608,16 @@ function sanctoralCommemorationPart(commemoration: Extract<Commemoration, { kind
if (combined.status.la !== 'missing' || combined.status.en !== 'missing') {
return { kind: 'preces', text: combined, label };
}
return { kind: 'preces', text: resolveCommon(`${saint.propers}-collect`), label };
const properCollect = resolveCommon(`${saint.propers}-collect`);
if (properCollect.status.la !== 'missing' || properCollect.status.en !== 'missing') {
return { kind: 'preces', text: properCollect, label };
}
}
if (saint?.collectCommon && saint.collectName) {
const template = resolveCommon(saint.collectCommon);
if (template.status.la !== 'missing' || template.status.en !== 'missing') {
return { kind: 'preces', text: substituteName(template, saint.collectName), label };
}
}
return { kind: 'preces', text: { text: {}, status: { la: 'missing', en: 'missing' } }, label };
}
+26 -14
View File
@@ -5,7 +5,7 @@ import { weekdayOf } from '../calendar/weekday';
import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle';
import { getVespersPsalmodyOverride } from './vespers-psalmody-overrides';
import { getVespersSaintOverride, getVespersCommonOverride } from './vespers-psalmody-overrides';
import { applyFlexaMark } from './antiphon';
import {
resolveCommon,
@@ -18,6 +18,7 @@ import {
seasonalOfficeSuffix,
isFerialOrVigil,
getOfficeOverrideId,
getPsalmodyProperOverrideId,
resolveResponsory,
resolveOfficeBundle,
resolveSuffrages,
@@ -70,20 +71,31 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
return parts;
}
/** A duplex-majus+ saint's own proper Vespers psalmody (none authored
* yet), or their Common's (live-verified 2026-08-28, see
* hours/vespers-psalmody-overrides.ts's own doc comment for why this
* mechanism is genuinely per-Common — unlike Lauds, the psalm *numbers*
* themselves differ by Common here, not just the antiphons). Eligibility
* follows the evening's governing `day` (same getOfficeOverrideId test
* as the chapter/responsory/hymn/versicle bundle below) — a First
* Vespers of a majus+ feast should get that feast's own psalms, same as
* it gets that feast's own office. Falls back to `undefined` for an
* eligible feast with no override authored yet, same honest incremental-
* content convention as everywhere else. */
/** Two independent tiers, tried in order — same split as hours/lauds.ts's
* own getPsalmodyOverrideFor, see its doc comment for the full rationale:
*
* 1. **Proper** — this feast's own authored psalmody, eligible at *any
* rank* via resolve-common.ts's getPsalmodyProperOverrideId (a saint's
* own real proper text is never gated behind rank).
* 2. **Common category** — per hours/vespers-psalmody-overrides.ts's own
* doc comment, genuinely per-Common here (the psalm *numbers*
* themselves differ by Common, not just the antiphons, unlike Lauds)
* — still gated at duplex-majus+ via getOfficeOverrideId.
*
* Eligibility follows the evening's governing `day` (same
* resolveOfficeWinner-based test as the chapter/responsory/hymn/versicle
* bundle below) — a First Vespers of a feast should get that feast's own
* psalms, same as it gets that feast's own office. Falls back to
* `undefined` for an eligible feast with no override authored in either
* tier, same honest incremental-content convention as everywhere else. */
function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined {
const id = getOfficeOverrideId(day);
return id ? getVespersPsalmodyOverride(id) : undefined;
const properId = getPsalmodyProperOverrideId(day);
const proper = properId ? getVespersSaintOverride(properId) : undefined;
if (proper) {
return proper;
}
const categoryId = getOfficeOverrideId(day);
return categoryId ? getVespersCommonOverride(categoryId) : undefined;
}
/** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm
+13 -7
View File
@@ -26,12 +26,18 @@ function renderColumns(resolved: ResolvedText | ResolvedVerse, languages: readon
}
// Scripture quotations always carry a full citation (book, chapter, verse) —
// shown once, deduplicated across languages when they happen to match.
function renderCitation(text: ResolvedText): string {
// shown once per currently-displayed language, deduplicated when they
// happen to match (e.g. a plain "Psalm 118" citation reused verbatim in
// both columns) — restricted to `languages` so a language-specific
// abbreviation (Latin "Jac 1:12" vs. English "Jas 1:12") never leaks into
// a single-language view.
function renderCitation(text: ResolvedText, languages: readonly string[]): string {
if (!text.citation) {
return '';
}
const values = [...new Set(Object.values(text.citation).filter((v): v is string => Boolean(v)))];
const values = [
...new Set(languages.map((lang) => text.citation![lang]).filter((v): v is string => Boolean(v))),
];
return values.length ? `<p class="ordo-part-citation">${escapeHtml(values.join(' / '))}</p>` : '';
}
@@ -64,7 +70,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
return `
<section class="ordo-part ordo-part-${part.kind}">
<h3 class="ordo-part-label">${hourLabel(part.kind)}</h3>
${renderCitation(part.text)}
${renderCitation(part.text, languages)}
${renderColumns(part.text, languages)}
</section>
`;
@@ -93,7 +99,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
return `
<section class="ordo-part ordo-part-lesson">
${renderLessonLabel(part.label, languages)}
${renderCitation(part.text)}
${renderCitation(part.text, languages)}
${renderColumns(part.text, languages)}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
</section>
@@ -110,7 +116,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
return `
<section class="ordo-part ordo-part-lesson ordo-part-gospel">
<h3 class="ordo-part-label">Gospel</h3>
${renderCitation(part.text)}
${renderCitation(part.text, languages)}
${renderColumns(part.text, languages)}
${part.source ? `<p class="ordo-part-citation">${escapeHtml(part.source)}</p>` : ''}
${part.responsory ? `<div class="ordo-part-responsory">${renderColumns(part.responsory, languages)}</div>` : ''}
@@ -170,7 +176,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
<section class="ordo-part ordo-part-canticle">
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
<h3 class="ordo-part-label lang-columns lang-columns-${languages.length}">${titleCols}</h3>
${renderCitation(part.text)}
${renderCitation(part.text, languages)}
${renderColumns(part.text, languages)}
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section>