feat(faith): render 1962 Mass propers with scripture refs and Bible fallback

Show propers text for each 1962 celebration with scripture reference pills
grouping each block. When a translated proper is missing, fall back to the
local-language Bible (Douay-Rheims for en, Allioli for de), showing a note
above the translated column. Handles multi-segment refs (e.g. "Ps 118:85;
118:46") with inherited book/chapter, and shifts Vulgate→Hebrew psalm
numbering for Allioli.

Also restructures date navigation as folder-based optional params
(/yyyy/mm/dd) with the rite forced as a required path segment so day/month
navigation stays within the active rite.
This commit is contained in:
2026-04-14 22:41:17 +02:00
parent 3a2e2a9408
commit 2970dc0451
10 changed files with 618 additions and 72 deletions
@@ -0,0 +1,13 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { expectedSlug } from './calendarI18n';
export const load: PageServerLoad = async ({ params, url }) => {
const slug = expectedSlug(params.faithLang);
if (slug === null) throw error(404, 'Not found');
if (params.calendar !== slug) {
throw redirect(307, `/${params.faithLang}/${slug}`);
}
const search = url.search ?? '';
throw redirect(307, `/${params.faithLang}/${params.calendar}/1962${search}`);
};
@@ -15,7 +15,11 @@ import {
season1962Label,
type CalendarLang,
type Rite
} from '../calendarI18n';
} from '../../../../calendarI18n';
import { getProperSegments } from '$lib/server/romcal1962Refs';
import { lookupReference } from '$lib/server/bible';
import { translateRefToTarget } from '$lib/server/bibleRefLatin';
import { resolve as resolvePath } from 'path';
export interface CalendarDay {
iso: string;
@@ -71,7 +75,6 @@ const localeBundles = {
la: GeneralRoman_La
};
// Cache: lang -> Romcal instance
const romcalByLang = new Map<CalendarLang, Romcal>();
function getRomcal(lang: CalendarLang): Romcal {
let r = romcalByLang.get(lang);
@@ -81,7 +84,6 @@ function getRomcal(lang: CalendarLang): Romcal {
return r;
}
// Cache: lang|year -> Map<iso, CalendarDay>
const yearCache = new Map<string, Map<string, CalendarDay>>();
async function getYear(lang: CalendarLang, year: number): Promise<Map<string, CalendarDay>> {
@@ -142,10 +144,21 @@ const PROPER_ORDER = [
type ProperKey = (typeof PROPER_ORDER)[number];
export interface ProperSection {
key: string;
export interface ProperSegment {
refs: string[];
la: string;
local?: string;
// When true, `local` text comes from the Bible translation lookup because
// the propers dataset had no localized text for this segment.
fromBible?: boolean;
}
export interface ProperSection {
key: string;
segments: ProperSegment[];
// Aggregate list of refs across segments (for quick checks)
refs: string[];
fromBible?: boolean;
}
const COLOR_KEY_1962: Record<string, string> = {
@@ -190,15 +203,93 @@ function textOf(dict: Record<string, string> | undefined, locale: string): strin
return v && v.trim() ? v : '';
}
function bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null {
const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv');
const segments = ref.split(';').map((s) => s.trim()).filter(Boolean);
if (!segments.length) return null;
let lastBook: string | null = null;
let lastChapter: string | null = null;
const parts: string[] = [];
for (const seg of segments) {
// Detect optional leading book (letters, optional leading digit like "1 Cor")
const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/);
let book: string | null = null;
let rest = seg;
if (bookMatch) {
book = bookMatch[1];
rest = bookMatch[2].trim();
}
if (book) lastBook = book;
if (!lastBook) continue;
let chapter: string;
let verseRange: string;
// Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter)
const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim();
if (normalized.includes(':')) {
const [c, v] = normalized.split(':');
chapter = c.trim();
verseRange = v.trim();
lastChapter = chapter;
} else if (lastChapter) {
chapter = lastChapter;
verseRange = normalized;
} else {
continue;
}
const fullRef = `${lastBook} ${chapter}:${verseRange}`;
const translated = translateRefToTarget(fullRef, targetLang);
if (!translated) continue;
try {
const result = lookupReference(translated, tsvPath);
if (result && result.verses.length) {
parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' '));
}
} catch {
// skip this segment
}
}
return parts.length ? parts.join(' ') : null;
}
function propersOf(p: Celebration1962, lang: CalendarLang): ProperSection[] {
const out: ProperSection[] = [];
const m = p.propers;
if (!m) return out;
const source = p.properRef.source;
for (const key of PROPER_ORDER) {
const la = textOf(m[key as ProperKey], 'la');
const local = lang === 'la' ? '' : textOf(m[key as ProperKey], lang);
if (!la && !local) continue;
out.push({ key, la, ...(local ? { local } : {}) });
const rawSegs = getProperSegments(source, key, lang);
if (!rawSegs || !rawSegs.length) continue;
const segments: ProperSegment[] = [];
const allRefs: string[] = [];
let sectionFromBible = false;
for (const raw of rawSegs) {
const seg: ProperSegment = { refs: raw.refs, la: raw.la };
if (lang !== 'la' && raw.local) seg.local = raw.local;
// Bible fallback: only for this segment, using only its own refs
if (!seg.local && raw.refs.length && lang !== 'la') {
const bible = bibleTextFor(raw.refs.join('; '), lang);
if (bible) {
seg.local = bible;
seg.fromBible = true;
sectionFromBible = true;
}
}
allRefs.push(...raw.refs);
if (seg.la || seg.local || seg.refs.length) segments.push(seg);
}
if (!segments.length) continue;
const section: ProperSection = { key, segments, refs: allRefs };
if (sectionFromBible) section.fromBible = true;
out.push(section);
}
return out;
}
@@ -209,14 +300,17 @@ function extraSectionsOf(p: Celebration1962, lang: CalendarLang): ProperSection[
const out: ProperSection[] = [];
for (const [key, block] of Object.entries(extras)) {
const buckets: Record<string, string[]> = {};
const refs: string[] = [];
for (const item of block) {
if (item.type !== 'text') continue;
(buckets[item.lang] ??= []).push(item.value);
if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value);
else if (item.type === 'scriptureRef') refs.push(item.ref);
}
const la = (buckets['la'] ?? []).join('\n\n').trim();
const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim();
if (!la && !local) continue;
out.push({ key, la, ...(local ? { local } : {}) });
if (!la && !local && refs.length === 0) continue;
const segment: ProperSegment = { refs, la };
if (local) segment.local = local;
out.push({ key, segments: [segment], refs });
}
return out;
}
@@ -300,21 +394,21 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const lang: CalendarLang =
params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
const rite: Rite = params.rite === '1969' ? '1969' : '1962';
// Reject mm without yyyy, dd without yyyy+mm. Sveltekit optional routes let
// gaps through so we normalize here.
if ((params.mm && !params.yyyy) || (params.dd && !params.mm)) {
throw error(404, 'Not found');
}
const today = new Date();
// Rite lives in the optional [[year]] route segment (1962 | 1969). When
// absent we default to 1962, the new tridentine calendar.
const rite: Rite = params.year === '1969' ? '1969' : '1962';
const yParam = url.searchParams.get('y');
const mParam = url.searchParams.get('m');
const selectedDateParam = url.searchParams.get('d');
const minYear = rite === '1962' ? 1900 : 1969;
const y = yParam !== null ? Number(yParam) : NaN;
const m = mParam !== null ? Number(mParam) : NaN;
const yParam = params.yyyy ? Number(params.yyyy) : NaN;
const mParam = params.mm ? Number(params.mm) - 1 : NaN;
const year = Number.isFinite(y) && y >= minYear && y <= 2100 ? y : today.getFullYear();
const month = Number.isFinite(m) && m >= 0 && m <= 11 ? m : today.getMonth();
const year = Number.isFinite(yParam) && yParam >= minYear && yParam <= 2100 ? yParam : today.getFullYear();
const month = Number.isFinite(mParam) && mParam >= 0 && mParam <= 11 ? mParam : today.getMonth();
const yearMap =
rite === '1962' ? await getYear1962(lang, year) : await getYear(lang, year);
@@ -348,8 +442,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const todayEntry = todayYearMap.get(todayIso) ?? null;
let selectedIso: string;
if (selectedDateParam && /^\d{4}-\d{2}-\d{2}$/.test(selectedDateParam)) {
selectedIso = selectedDateParam;
if (params.dd) {
const dayNum = Number(params.dd);
if (dayNum < 1 || dayNum > daysInMonth) throw error(404, 'Not found');
selectedIso = isoFor(year, month, dayNum);
} else if (todayEntry && today.getFullYear() === year && today.getMonth() === month) {
selectedIso = todayIso;
} else {
@@ -13,7 +13,7 @@
t1962,
properLabel,
type CalendarLang
} from '../calendarI18n';
} from '../../../../calendarI18n';
let { data }: { data: PageData } = $props();
@@ -41,40 +41,42 @@
return (firstDow + 6) % 7;
});
const rite = $derived(data.rite);
const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === '1962' ? 'rite1962Long' : 'rite1969Long', lang));
function pad(n: number) {
return String(n).padStart(2, '0');
}
// URL: /{faithLang}/{calendar}/{rite}/{yyyy}/{mm}/{dd} — rite is a required
// path segment so day/month nav stays inside the active rite.
const riteBase = $derived(`/${page.params.faithLang}/${page.params.calendar}/${rite}`);
const calendarBase = $derived(`/${page.params.faithLang}/${page.params.calendar}`);
function dayHref(iso: string) {
return `?y=${year}&m=${month}&d=${iso}`;
const [yy, mm, dd] = iso.split('-');
return `${riteBase}/${yy}/${mm}/${dd}`;
}
function monthHref(y: number, m: number) {
return `?y=${y}&m=${m}`;
return `${riteBase}/${y}/${pad(m + 1)}`;
}
const todayHref = $derived.by(() => {
const now = new Date();
return `?y=${now.getFullYear()}&m=${now.getMonth()}&d=${todayIso}`;
return `${riteBase}/${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())}`;
});
const pageTitle = $derived(t('calendar', lang));
const rite = $derived(data.rite);
const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === '1962' ? 'rite1962Long' : 'rite1969Long', lang));
function firstOr(arr: string[], fallback = ''): string {
return arr && arr.length ? arr[0] : fallback;
}
const calendarBase = $derived(
page.url.pathname.replace(/\/(1962|1969)\/?$/, '').replace(/\/$/, '')
);
function riteHref(r: '1969' | '1962') {
const seg = r === '1962' ? '' : '/1969';
const params = new URLSearchParams();
params.set('y', String(year));
params.set('m', String(month));
if (selectedIso) params.set('d', selectedIso);
return `${calendarBase}${seg}?${params.toString()}`;
const dd = selectedIso.slice(8, 10);
return `${calendarBase}/${r}/${year}/${pad(month + 1)}/${dd}`;
}
</script>
@@ -311,13 +313,33 @@
<h4>{t1962('propers', lang)}</h4>
{#each d.propers as section (section.key)}
<div class="proper-block">
<div class="proper-label">{properLabel(section.key, lang)}</div>
<div class="proper-cols" class:single={lang === 'la' || !section.local}>
<div class="proper-col proper-col-la" lang="la">{section.la}</div>
{#if lang !== 'la' && section.local}
<div class="proper-col proper-col-local" lang={lang}>{section.local}</div>
{/if}
<div class="proper-label-row">
<span class="proper-label">{properLabel(section.key, lang)}</span>
</div>
{#each section.segments as seg, segIdx (segIdx)}
<div class="proper-segment">
{#if seg.refs && seg.refs.length}
<div class="proper-segment-refs">
{#each seg.refs as r (r)}
<span class="proper-ref">{r}</span>
{/each}
</div>
{/if}
{#if seg.la || seg.local}
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
{#if lang !== 'la' && seg.local && seg.fromBible}
<p class="proper-fallback-note">{t1962('bibleFallbackNote', lang)}</p>
{/if}
{#if seg.la}
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
{/if}
{#if lang !== 'la' && seg.local}
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/each}
</section>
@@ -327,13 +349,26 @@
<h4>{t1962('extraSections', lang)}</h4>
{#each d.extraSections as section (section.key)}
<div class="proper-block">
<div class="proper-label">{properLabel(section.key, lang)}</div>
<div class="proper-cols" class:single={lang === 'la' || !section.local}>
<div class="proper-col proper-col-la" lang="la">{section.la}</div>
{#if lang !== 'la' && section.local}
<div class="proper-col proper-col-local" lang={lang}>{section.local}</div>
{/if}
<div class="proper-label-row">
<span class="proper-label">{properLabel(section.key, lang)}</span>
</div>
{#each section.segments as seg, segIdx (segIdx)}
<div class="proper-segment">
{#if seg.refs && seg.refs.length}
<div class="proper-segment-refs">
{#each seg.refs as r (r)}
<span class="proper-ref">{r}</span>
{/each}
</div>
{/if}
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
{#if lang !== 'la' && seg.local}
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
</section>
@@ -372,7 +407,6 @@
font-style: italic;
}
/* --- Rite toggle (segmented pill) --- */
.rite-toggle {
display: inline-flex;
gap: 0.25rem;
@@ -411,7 +445,6 @@
outline-offset: 2px;
}
/* --- WIP placeholder --- */
.wip {
display: flex;
flex-direction: column;
@@ -441,7 +474,6 @@
line-height: 1.5;
}
/* --- 1962 accuracy disclaimer --- */
.disclaimer {
display: flex;
align-items: flex-start;
@@ -471,7 +503,6 @@
margin: 0;
}
/* --- Today hero --- */
.today-hero {
position: relative;
background: var(--color-surface);
@@ -547,7 +578,6 @@
display: inline-block;
}
/* --- Month nav --- */
.month-nav {
display: flex;
align-items: center;
@@ -603,7 +633,6 @@
transform: scale(1.03);
}
/* --- Grid --- */
.grid {
background: var(--color-surface);
border-radius: var(--radius-card);
@@ -716,7 +745,6 @@
z-index: 1;
}
/* --- Detail panel --- */
.detail {
background: var(--color-surface);
border-radius: var(--radius-card);
@@ -846,22 +874,80 @@
.proper-block {
margin-bottom: 0.75rem;
}
.proper-label-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.proper-label {
font-weight: 600;
font-size: 0.8rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.2rem;
}
.proper-ref {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-pill);
font-size: 0.72rem;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.proper-segment {
margin-top: 0.5rem;
}
.proper-segment:first-child {
margin-top: 0;
}
.proper-segment + .proper-segment {
padding-top: 0.5rem;
border-top: 1px dashed var(--color-border);
}
.proper-segment-refs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.proper-fallback-note {
grid-column: 2 / 3;
grid-row: 1;
margin: 0 0 0.25rem;
padding: 0.4rem 0.6rem;
border-left: 2px solid color-mix(in srgb, var(--orange) 55%, transparent);
background: color-mix(in srgb, var(--orange) 8%, transparent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.proper-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
column-gap: 0.75rem;
row-gap: 0;
align-items: start;
}
.proper-col-la {
grid-column: 1;
grid-row: 2;
}
.proper-col-local {
grid-column: 2;
grid-row: 2;
}
.proper-cols.single {
grid-template-columns: 1fr;
}
.proper-cols.single .proper-col-la,
.proper-cols.single .proper-col-local {
grid-column: 1;
grid-row: auto;
}
.proper-col {
white-space: pre-wrap;
font-size: 0.92rem;
@@ -879,9 +965,14 @@
.proper-cols {
grid-template-columns: 1fr;
}
.proper-cols .proper-col-la,
.proper-cols .proper-col-local,
.proper-cols .proper-fallback-note {
grid-column: 1;
grid-row: auto;
}
}
/* --- Responsive --- */
@media (max-width: 560px) {
.cal-wrap {
padding: 0.5rem 0.5rem 3rem;
@@ -197,11 +197,16 @@ export const ui1962 = {
no: { en: 'no', de: 'nein', la: 'non' },
properRef: { en: 'Proper', de: 'Proprium', la: 'Proprium' },
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' },
extraSections: { en: 'Additional readings', de: 'Zusätzliche Lesungen', la: 'Lectiones additae' }
extraSections: { en: 'Additional readings', de: 'Zusätzliche Lesungen', la: 'Lectiones additae' },
bibleFallbackNote: {
en: 'Translation taken from the Douay-Rheims Bible, since no translated proper is published for this section. Wording will differ from authoritative missals.',
de: 'Übersetzung stammt aus der Allioli-Bibel, da für diesen Abschnitt kein übersetztes Proprium veröffentlicht ist. Wortlaut weicht von maßgeblichen Messbüchern ab.'
}
} as const;
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {
return ui1962[key][lang] ?? ui1962[key].en;
const entry = ui1962[key] as Record<string, string | undefined>;
return entry[lang] ?? entry.en ?? '';
}
const PROPER_LABEL: Record<string, Record<CalendarLang, string>> = {