feat(faith): Bible fallback for 1962 propers with propers view toggle

- Auto-fill missing vernacular propers from Allioli (DE) or DRB (EN)
  when the 1962 missal bundle lacks a translation, mapped per Latin slot
  via romcal's scriptureRef blocks (compound refs split 1-to-1 when
  segment count matches slot count).
- Strip Psalm superscriptions and trailing periods so lookups parse and
  Bible text aligns with the Latin antiphon.
- Localize the section reference header (Marc → Mk, Vulgate→Hebrew
  psalm shift for DE) instead of showing raw Latin.
- Add Latin / Parallel / Vernacular view toggle with localStorage
  persistence; hide Allioli/DRB badge in Latin-only view.
- Latin column now takes primary text color; vernacular secondary,
  matching the Prayer.svelte convention.
This commit is contained in:
2026-04-21 11:01:42 +02:00
parent 845e2eefa3
commit 57cd16085c
8 changed files with 417 additions and 95 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.39.0",
"version": "1.40.0",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -63,4 +63,7 @@ export interface ProperSection {
key: string;
la: string[];
local: string[];
refs?: string[];
refLabel?: string;
localFromBible?: boolean;
}
+27 -1
View File
@@ -116,7 +116,9 @@ export function translateRefToTarget(ref: string, lang: TargetLang): string | nu
const m = ref.trim().match(/^(\d?\s?[A-Za-z]+\.?)\s*(\d.*)$/);
if (!m) return null;
const bookNorm = normalizeLatinBook(m[1]);
const rest = m[2].trim().replace(/;.*$/, '').trim();
// Strip trailing punctuation ("Marc 16:1-7." → "16:1-7") — bible.ts's
// parseReference regex anchors on digits and refuses trailing periods.
const rest = m[2].trim().replace(/;.*$/, '').replace(/[.\s]+$/, '').trim();
const map = lookupLatinBook(bookNorm);
const target = map?.[lang];
if (!target) return null;
@@ -132,3 +134,27 @@ export function translateRefToTarget(ref: string, lang: TargetLang): string | nu
}
return `${target} ${clean}`;
}
// Translate the book name only, preserving the rest of the citation (including
// compound refs with semicolons, verse ranges, etc.) for display purposes.
// Strips trailing periods but keeps the structure readable.
export function translateRefLabel(ref: string, lang: TargetLang | 'la'): string {
const trimmed = ref.trim().replace(/[.\s]+$/, '');
if (lang === 'la') return trimmed;
const m = trimmed.match(/^(\d?\s?[A-Za-z]+\.?)\s*(.*)$/);
if (!m) return trimmed;
const bookNorm = normalizeLatinBook(m[1]);
const map = lookupLatinBook(bookNorm);
const target = map?.[lang];
if (!target) return trimmed;
const rest = m[2].trim();
// Psalm numbering differs between Vulgate and Allioli — shift each
// chapter reference found in the remainder.
if (bookNorm === 'ps' && lang === 'de') {
const shifted = rest.replace(/(^|[\s;,])(\d+)(?=[:,\s-])/g, (_, pre, ch) => {
return `${pre}${mapPsalmChapter(parseInt(ch, 10), 'de')}`;
});
return `${target} ${shifted}`;
}
return `${target} ${rest}`;
}
+37
View File
@@ -42,6 +42,9 @@ import type {
Rite1962Commem,
Rite1962Detail
} from '../calendarTypes';
import { getProperRefs, getProperRefsPerSlot } from './romcal1962Refs';
import { fetchLocalFromBible, type FallbackLang } from './properBibleFallback';
import { translateRefLabel } from './bibleRefLatin';
// romcal's package.json isn't exposed via its exports map, so resolve the
// main entry instead and walk up until we hit the package root.
@@ -340,6 +343,40 @@ function adaptDay1962(
const laProps = findPropersFor(d, laBundle);
const localProps = localBundle ? findPropersFor(d, localBundle) : undefined;
const propers = sectionsFromBundle(laProps, localProps);
const properKey = d.key1962 ?? d.id;
const fallbackLang: FallbackLang | null =
lang === 'en' ? 'en' : lang === 'de' ? 'de' : null;
const labelLang: 'en' | 'de' | 'la' = lang === 'en' ? 'en' : lang === 'de' ? 'de' : 'la';
for (const section of propers) {
const refs = getProperRefs(d.kind1962, properKey, section.key);
if (refs.length) {
section.refs = refs;
section.refLabel = translateRefLabel(refs[0], labelLang);
}
if (fallbackLang && section.local.length === 0 && refs.length) {
const perSlot = getProperRefsPerSlot(
d.kind1962,
properKey,
section.key,
section.la.length
);
const localArr: string[] = new Array(section.la.length).fill('');
let any = false;
for (let i = 0; i < section.la.length; i++) {
const slotRefs = perSlot[i];
if (!slotRefs || slotRefs.length === 0) continue;
const text = fetchLocalFromBible(slotRefs, fallbackLang);
if (text) {
localArr[i] = text;
any = true;
}
}
if (any) {
section.local = localArr;
section.localFromBible = true;
}
}
}
const detail: Rite1962Detail = {
class: classOf,
+62
View File
@@ -0,0 +1,62 @@
import { resolve } from 'path';
import { translateRefToTarget } from './bibleRefLatin';
import { lookupReference } from './bible';
export type FallbackLang = 'en' | 'de';
const TSV_PATH: Record<FallbackLang, string> = {
en: 'static/drb.tsv',
de: 'static/allioli.tsv'
};
// Latin propers often cite successive verses in compact form like
// "Ps 80:2; 80:3; 80:4; 80:5" (book implicit after the first segment) or
// "Ps 32:12 ; 32:6". Expand each segment into a standalone "Book Chap:Verse"
// reference so downstream parsing can handle them one at a time.
export function splitCompoundRef(ref: string): string[] {
const parts = ref
.split(/\s*;\s*/)
.map((s) => s.trim())
.filter(Boolean);
if (parts.length <= 1) return parts;
const bookMatch = parts[0].match(/^(\d?\s?[A-Za-z.]+)/);
if (!bookMatch) return parts;
const book = bookMatch[0].trim();
return parts.map((p, i) => (i === 0 || !/^\d/.test(p) ? p : `${book} ${p}`));
}
// Allioli (and DRB) include the Psalm superscription ("Zum Ende, ein Psalm
// Davids.", "Unto the end, a psalm of David.") as the opening of verse 1.
// The Latin propers always skip the title phrase, so strip it when our
// fallback lookup pulls a Psalm verse 1 in.
const PS_TITLE_DE = /^(Zum Ende|Ein (Psalm|Gebet|Lied|Lobpsalm|Gesang|Hymnus)|Eine Unterweisung|Aufschrift|Am (ersten|Sabbat)|Loblied|Psalm Davids|Dem Sangmeister)/;
const PS_TITLE_EN = /^(Unto the end|A psalm|A canticle|A song|To the end|For the end|An instruction|Of David)/i;
function stripPsalmSuperscription(text: string, lang: FallbackLang): string {
const re = lang === 'de' ? PS_TITLE_DE : PS_TITLE_EN;
if (!re.test(text)) return text;
// Drop the first sentence (ends at first period/exclamation/question).
return text.replace(/^[^.!?]*[.!?]\s*/, '').trim();
}
export function fetchLocalFromBible(refs: string[], lang: FallbackLang): string | null {
if (!refs || refs.length === 0) return null;
const tsvPath = resolve(TSV_PATH[lang]);
const collected: string[] = [];
for (const rawRef of refs) {
for (const seg of splitCompoundRef(rawRef)) {
const translated = translateRefToTarget(seg, lang);
if (!translated) continue;
const lookup = lookupReference(translated, tsvPath);
if (!lookup) continue;
const isPsalm = /^Ps\s/i.test(translated);
const parts = lookup.verses.map((v) => {
if (isPsalm && v.verse === 1) return stripPsalmSuperscription(v.text, lang);
return v.text;
});
const text = parts.join(' ').trim();
if (text) collected.push(text);
}
}
if (collected.length === 0) return null;
return collected.join(' ');
}
+102
View File
@@ -0,0 +1,102 @@
import { readFileSync } from 'fs';
import { createRequire } from 'module';
import { splitCompoundRef } from './properBibleFallback';
type Block =
| { type: 'text'; slot?: number; role?: string }
| { type: 'scriptureRef'; ref: string }
| { type: 'directive'; value: string }
| { type: 'rubric'; note: string }
| { type: 'ref'; target: string }
| { type: 'separator' };
type Entry = Record<string, Block[]>;
const require = createRequire(import.meta.url);
function loadStructure(name: 'tempora' | 'sancti' | 'commune'): Record<string, Entry> {
try {
const pkgEntry = require.resolve('romcal/1962');
const baseIdx = pkgEntry.indexOf('/rites/roman1962/');
if (baseIdx < 0) return {};
const dataPath =
pkgEntry.slice(0, baseIdx) +
`/rites/roman1962/data/propers/_structure/${name}.json`;
return JSON.parse(readFileSync(dataPath, 'utf-8')) as Record<string, Entry>;
} catch {
return {};
}
}
const structures: Record<'tempora' | 'sancti' | 'commune', Record<string, Entry>> = {
tempora: loadStructure('tempora'),
sancti: loadStructure('sancti'),
commune: loadStructure('commune')
};
export function getProperRefs(
kind: string | null | undefined,
key: string,
section: string
): string[] {
if (!kind) return [];
const entry = structures[kind as keyof typeof structures]?.[key];
if (!entry) return [];
const blocks = entry[section];
if (!blocks) return [];
const refs: string[] = [];
for (const b of blocks) {
if (b.type === 'scriptureRef' && b.ref) refs.push(b.ref);
}
return refs;
}
// Distribute scripture refs across Latin text slots so Bible fallback aligns
// row-for-row with the Latin. Each scriptureRef block applies to the text
// slots that follow it (up to the next scriptureRef). When a compound ref
// like "Ps. 117:24; 117:1" has the same number of segments as following
// slots, segments map 1-to-1. Otherwise all segments collapse into the first
// following slot so multi-verse antiphons stay intact.
export function getProperRefsPerSlot(
kind: string | null | undefined,
key: string,
section: string,
slotCount: number
): string[][] {
const perSlot: string[][] = Array.from({ length: slotCount }, () => []);
if (!kind || slotCount <= 0) return perSlot;
const entry = structures[kind as keyof typeof structures]?.[key];
if (!entry) return perSlot;
const blocks = entry[section];
if (!blocks) return perSlot;
let i = 0;
while (i < blocks.length) {
const b = blocks[i];
if (b.type !== 'scriptureRef' || !b.ref) {
i++;
continue;
}
const segs = splitCompoundRef(b.ref);
const slotIdxs: number[] = [];
let j = i + 1;
while (j < blocks.length && blocks[j].type !== 'scriptureRef') {
const nb = blocks[j];
if (nb.type === 'text' && typeof nb.slot === 'number') slotIdxs.push(nb.slot);
j++;
}
if (slotIdxs.length > 0) {
if (segs.length === slotIdxs.length) {
for (let k = 0; k < segs.length; k++) {
const s = slotIdxs[k];
if (s >= 0 && s < slotCount) perSlot[s].push(segs[k]);
}
} else {
const first = slotIdxs[0];
if (first >= 0 && first < slotCount) perSlot[first].push(...segs);
}
}
i = j;
}
return perSlot;
}
@@ -1,10 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
import { page } from '$app/state';
import { browser } from '$app/environment';
import {
formatLongDate,
getMonthName,
hexFor,
properLabel,
t,
t1962,
@@ -22,7 +22,16 @@
const iso = $derived(data.iso);
const todayIso = $derived(data.todayIso);
const dayHex = $derived(hexFor(day.colorKeys));
type PropersView = 'la' | 'parallel' | 'local';
const VIEW_KEY = 'litcal.propersView';
let propersView = $state<PropersView>('parallel');
if (browser) {
const saved = localStorage.getItem(VIEW_KEY);
if (saved === 'la' || saved === 'parallel' || saved === 'local') propersView = saved;
}
$effect(() => {
if (browser) localStorage.setItem(VIEW_KEY, propersView);
});
function pad(n: number) {
return String(n).padStart(2, '0');
@@ -77,7 +86,7 @@
{#if day.rite1962}
{@const d = day.rite1962}
<section class="detail" style="--accent: {dayHex}">
{#if d.vigilOf || d.octave || d.transferredFrom}
<dl class="detail-extras">
{#if d.vigilOf}
<div>
@@ -98,48 +107,84 @@
</div>
{/if}
</dl>
{#if d.commemorations.length}
<div class="commems">
<h4>{t1962('commemorations', lang)}</h4>
<ul>
{#each d.commemorations as c (c.id)}
<li>
<span class="commem-name">{c.name}</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if d.propers.length}
<section class="propers">
{/if}
{#if d.propers.length}
<section class="propers">
<div class="propers-head">
<h4>{t1962('propers', lang)}</h4>
{#each d.propers as section (section.key)}
{@const rows = Math.max(section.la.length, section.local.length)}
<div class="proper-block">
<div class="proper-label-row">
<span class="proper-label">{properLabel(section.key, lang)}</span>
</div>
{#if lang !== 'la'}
<div class="view-toggle" role="group" aria-label={t1962('propers', lang)}>
<button
type="button"
class="view-btn"
class:active={propersView === 'la'}
aria-pressed={propersView === 'la'}
onclick={() => (propersView = 'la')}
>
{t1962('viewLatin', lang)}
</button>
<button
type="button"
class="view-btn"
class:active={propersView === 'parallel'}
aria-pressed={propersView === 'parallel'}
onclick={() => (propersView = 'parallel')}
>
{t1962('viewParallel', lang)}
</button>
<button
type="button"
class="view-btn"
class:active={propersView === 'local'}
aria-pressed={propersView === 'local'}
onclick={() => (propersView = 'local')}
>
{t1962('viewVernacular', lang)}
</button>
</div>
{/if}
</div>
{#each d.propers as section (section.key)}
{@const rows = Math.max(section.la.length, section.local.length)}
<article class="proper-card">
<header class="proper-card-head">
<span class="proper-label">{properLabel(section.key, lang)}</span>
{#if section.refLabel}
<span class="proper-ref">({section.refLabel})</span>
{/if}
{#if section.localFromBible && lang !== 'la' && propersView !== 'la'}
<span class="proper-fallback" title={t1962('fallbackHint', lang)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>
{t1962('fallbackBadge', lang)}
</span>
{/if}
</header>
<div class="proper-content">
{#each Array(rows) as _, i (i)}
{@const la = section.la[i] ?? ''}
{@const local = section.local[i] ?? ''}
{#if la || local}
{@const isLaOnly = lang === 'la' || propersView === 'la'}
{@const isLocalOnly = lang !== 'la' && propersView === 'local'}
{@const showLa = isLaOnly || propersView === 'parallel' || (isLocalOnly && !local)}
{@const showLocal = !isLaOnly && local && (propersView === 'parallel' || isLocalOnly)}
{#if (showLa && la) || (showLocal && local)}
<div class="proper-segment">
<div class="proper-cols" class:single={lang === 'la' || !local}>
{#if la}
<div class="proper-cols" class:single={!(showLa && la && showLocal && local)}>
{#if showLa && la}
<div class="proper-col proper-col-la" lang="la">{la}</div>
{/if}
{#if lang !== 'la' && local}
<div class="proper-col proper-col-local" lang={lang}>{local}</div>
{#if showLocal && local}
<div class="proper-col proper-col-local" {lang}>{local}</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/each}
</section>
{/if}
</section>
</article>
{/each}
</section>
{/if}
{/if}
<nav class="back-nav">
@@ -219,17 +264,8 @@
color: var(--color-text-primary);
}
.detail {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 1.25rem 1.5rem;
margin-top: 1.25rem;
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--accent);
}
.detail-extras {
margin: 0 0 0.5rem;
margin: 1.25rem 0 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem 1rem;
@@ -250,7 +286,6 @@
margin: 0;
color: var(--color-text-primary);
}
.commems h4,
.propers h4 {
margin: 0.5rem 0 0.4rem;
font-size: 0.72rem;
@@ -259,52 +294,106 @@
color: var(--color-text-secondary);
font-weight: 600;
}
.commems {
margin-top: 0.75rem;
}
.commems ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.commems li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm, 6px);
font-size: 0.85rem;
}
.commem-name {
flex: 1 1 auto;
color: var(--color-text-primary);
}
.propers {
margin-top: 1rem;
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-top: 1.5rem;
}
.proper-block {
margin-bottom: 0.75rem;
}
.proper-label-row {
.propers-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.propers-head h4 {
margin: 0;
}
.view-toggle {
display: inline-flex;
padding: 3px;
background: var(--color-bg-tertiary);
border-radius: var(--radius-pill);
gap: 2px;
}
.view-btn {
appearance: none;
border: none;
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.35rem 0.85rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.view-btn:hover {
color: var(--color-text-primary);
}
.view-btn.active {
background: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
.proper-card {
background: var(--color-bg-elevated);
border-radius: var(--radius-lg, 12px);
padding: 1.1rem 1.35rem 1.25rem;
margin-bottom: 0.9rem;
box-shadow: var(--shadow-sm);
}
.proper-card:last-child {
margin-bottom: 0;
}
.proper-card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.4rem;
margin-bottom: 0.6rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.proper-label {
font-weight: 600;
font-size: 0.8rem;
color: var(--color-text-secondary);
display: inline-block;
font-weight: 700;
font-size: 0.78rem;
color: var(--nord11);
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.08em;
line-height: 1.2;
}
.proper-ref {
margin-left: 0.4rem;
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-tertiary);
letter-spacing: 0;
text-transform: none;
}
.proper-fallback {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--nord12) 35%, transparent);
border-radius: var(--radius-pill);
letter-spacing: 0.02em;
cursor: help;
}
.proper-fallback svg {
flex-shrink: 0;
}
.proper-content {
min-width: 0;
}
.proper-segment {
margin-top: 0.5rem;
@@ -319,13 +408,12 @@
.proper-cols {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 0.75rem;
column-gap: 1.25rem;
row-gap: 0;
align-items: start;
}
.proper-col-la {
grid-column: 1;
font-style: italic;
}
.proper-col-local {
grid-column: 2;
@@ -339,10 +427,13 @@
}
.proper-col {
white-space: pre-wrap;
font-size: 0.92rem;
line-height: 1.5;
font-size: 1rem;
line-height: 1.6;
color: var(--color-text-primary);
}
.proper-cols:not(.single) .proper-col-local {
color: var(--color-text-secondary);
}
.back-nav {
margin-top: 1.5rem;
@@ -359,12 +450,4 @@
grid-column: 1;
}
}
@media (max-width: 560px) {
.detail-hero {
padding: 1.15rem 1.2rem;
}
.hero-name {
font-size: 1.5rem;
}
}
</style>
@@ -288,7 +288,16 @@ export const ui1962 = {
transferredFrom: { en: 'Transferred from', de: 'Übertragen von', la: 'Translatum ex' },
source: { en: 'Source', de: 'Quelle', la: 'Fons' },
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' },
stationChurch: { en: 'Station church', de: 'Stationskirche', la: 'Statio' }
stationChurch: { en: 'Station church', de: 'Stationskirche', la: 'Statio' },
viewLatin: { en: 'Latin', de: 'Latein', la: 'Latine' },
viewParallel: { en: 'Parallel', de: 'Parallel', la: 'Parallelum' },
viewVernacular: { en: 'English', de: 'Deutsch', la: 'Vernacula' },
fallbackBadge: { en: 'Douay-Rheims', de: 'Allioli', la: 'Vulgata' },
fallbackHint: {
en: 'Translation not provided in the missal. Text taken from the Douay-Rheims Bible at the cited reference.',
de: 'Keine Übersetzung im Messbuch vorhanden. Text aus der Allioli-Bibelübersetzung an der angegebenen Stelle.',
la: 'Interpretatio localis deest. Textus ex Biblia Sacra locis citatis.'
}
} as const;
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {