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
+121
View File
@@ -0,0 +1,121 @@
// Map Latin biblical book abbreviations (as used in romcal 1962 propers) to
// the abbreviations used by our bible TSV files (DRB for English, Allioli for
// German). Undefined entries indicate we cannot translate that book yet.
type TargetLang = 'en' | 'de';
// Canonical Latin abbrevs (normalized: lowercase, no periods). We match
// prefixes defensively — "Joan", "Joann", "Joannes" all map the same.
const LATIN_TO_TARGET: Record<string, { en?: string; de?: string }> = {
gen: { en: 'Gn', de: '1Mo' },
ex: { en: 'Ex', de: '2Mo' },
lev: { en: 'Lv', de: '3Mo' },
num: { en: 'Nm', de: '4Mo' },
deut: { en: 'Dt', de: '5Mo' },
jos: { en: 'Jos', de: 'Jos' },
iud: { en: 'Jgs', de: 'Ri' },
rut: { en: 'Ru', de: 'Rt' },
'1reg': { en: '1Kgs', de: '1Sam' },
'2reg': { en: '2Kgs', de: '2Sam' },
'3reg': { en: '3Kgs', de: '1Kö' },
'4reg': { en: '4Kgs', de: '2Kö' },
'1par': { en: '1Par', de: '1Chr' },
'2par': { en: '2Par', de: '2Chr' },
esd: { en: '1Esd', de: 'Esr' },
neh: { en: '2Esd', de: 'Neh' },
tob: { en: 'Tb' },
jdt: { en: 'Jdt' },
est: { en: 'Est', de: 'Est' },
job: { en: 'Jb', de: 'Hi' },
ps: { en: 'Ps', de: 'Ps' },
prov: { en: 'Prv', de: 'Spr' },
eccl: { en: 'Eccles', de: 'Pred' },
cant: { en: 'CCan', de: 'Hl' },
sap: { en: 'Wis' },
eccli: { en: 'Ecclus' },
is: { en: 'Is', de: 'Jes' },
jer: { en: 'Jer', de: 'Jer' },
thren: { en: 'Lam', de: 'Kla' },
bar: { en: 'Bar' },
ez: { en: 'Ez', de: 'Hes' },
dan: { en: 'Dn', de: 'Dan' },
os: { en: 'Os', de: 'Hos' },
joel: { en: 'Jl', de: 'Joe' },
am: { en: 'Am', de: 'Am' },
abd: { en: 'Ab', de: 'Ob' },
jon: { en: 'Jon', de: 'Jon' },
mich: { en: 'Mi', de: 'Mi' },
nah: { en: 'Na', de: 'Nah' },
hab: { en: 'Hb', de: 'Hab' },
soph: { en: 'Sph', de: 'Zeph' },
agg: { en: 'Ag', de: 'Hagg' },
zach: { en: 'Zac', de: 'Sach' },
mal: { en: 'Mal', de: 'Mal' },
'1mach': { en: '1Mac' },
'2mach': { en: '2Mac' },
matt: { en: 'Mt', de: 'Mt' },
marc: { en: 'Mk', de: 'Mk' },
luc: { en: 'Lk', de: 'Lk' },
joann: { en: 'Jn', de: 'Joh' },
act: { en: 'Acts', de: 'Apg' },
rom: { en: 'Rom', de: 'Röm' },
'1cor': { en: '1Cor', de: '1Kor' },
'2cor': { en: '2Cor', de: '2Kor' },
gal: { en: 'Gal', de: 'Gal' },
eph: { en: 'Eph', de: 'Eph' },
phil: { en: 'Phil', de: 'Phil' },
col: { en: 'Col', de: 'Kol' },
'1thess': { en: '1Thes', de: '1Thes' },
'2thess': { en: '2Thes', de: '2Thes' },
'1tim': { en: '1Tim', de: '1Tim' },
'2tim': { en: '2Tim', de: '2Tim' },
tit: { en: 'Ti', de: 'Tit' },
philm: { en: 'Phlm', de: 'Phim' },
heb: { en: 'Heb', de: 'Heb' },
jac: { en: 'Jas', de: 'Jak' },
'1pet': { en: '1Pt', de: '1Petr' },
'2pet': { en: '2Pt', de: '2Petr' },
'1joan': { en: '1Jn', de: '1Jo' },
'2joan': { en: '2Jn', de: '2Jo' },
'3joan': { en: '3Jn', de: '3Jo' },
jud: { en: 'Jude', de: 'Jud' },
apoc: { en: 'Apo', de: 'Offb' }
};
function normalizeLatinBook(raw: string): string {
return raw.toLowerCase().replace(/[.\s]/g, '');
}
// Allioli (de) TSV uses Hebrew/modern psalm numbering; DRB (en) uses Vulgate.
// Latin propers are Vulgate, so we shift for de. Only covers the clean +1
// range — Vulgate 9/10, 113115, 146/147 involve splits/merges and are
// returned unchanged (lookups may miss; acceptable for now).
function mapPsalmChapter(vulgate: number, lang: TargetLang): number {
if (lang !== 'de') return vulgate;
if (vulgate >= 10 && vulgate <= 112) return vulgate + 1;
if (vulgate >= 116 && vulgate <= 145) return vulgate + 1;
return vulgate;
}
export function translateRefToTarget(ref: string, lang: TargetLang): string | null {
// ref like "Luc 12:2-8" or "Ps 118:85" or "Matt 5, 17-19"
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();
const map = LATIN_TO_TARGET[bookNorm];
const target = map?.[lang];
if (!target) return null;
const clean = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ');
// Apply psalm numbering shift when the target bible uses a different scheme
if (bookNorm === 'ps') {
const cm = clean.match(/^(\d+)(.*)$/);
if (cm) {
const shifted = mapPsalmChapter(parseInt(cm[1], 10), lang);
return `${target} ${shifted}${cm[2]}`;
}
}
return `${target} ${clean}`;
}
+201
View File
@@ -0,0 +1,201 @@
import { readFileSync } from 'fs';
import { createRequire } from 'module';
type PropersBlockItem =
| { type: 'text'; lang: string; value: string; role?: string }
| { type: 'scriptureRef'; ref: string }
| { type: 'directive'; value: string }
| { type: 'ref'; target: string }
| { type: 'rubric'; note: string }
| { type: 'separator' };
interface RawEntry {
id?: string;
references?: Record<string, string>;
sections?: Record<string, PropersBlockItem[]>;
}
const require = createRequire(import.meta.url);
function loadJson(name: 'tempora' | 'sancti' | 'commune'): Record<string, RawEntry> {
// Resolve via package entry, then hop into the sibling /data/ folder.
// The package exports field only exposes dist/ so we read the file directly.
const pkgEntry = require.resolve('romcal/1962');
const baseIdx = pkgEntry.indexOf('/rites/roman1962/');
if (baseIdx < 0) throw new Error('cannot locate romcal/1962 data dir');
const dataPath = pkgEntry.slice(0, baseIdx) + `/rites/roman1962/data/${name}.json`;
return JSON.parse(readFileSync(dataPath, 'utf-8'));
}
const kinds: Record<string, Record<string, RawEntry>> = {
tempora: loadJson('tempora'),
sancti: loadJson('sancti'),
commune: loadJson('commune')
};
// Proper key → source section name candidates (tried in order)
const SECTION_ALIASES: Record<string, string[]> = {
introit: ['Introitus'],
collect: ['Oratio'],
epistle: ['Lectio', 'Epistola'],
gradual: ['Graduale'],
alleluia: ['GradualeP', 'Alleluia'],
tract: ['Tractus'],
sequence: ['Sequentia'],
gospel: ['Evangelium'],
offertory: ['Offertorium'],
secret: ['Secreta'],
preface: ['Praefatio'],
communion: ['Communio'],
postcommunion: ['Postcommunio']
};
function lookupEntry(source: string): RawEntry | null {
const [rawKind, ...rest] = source.split('/');
const kind = rawKind.toLowerCase();
const id = rest.join('/');
return kinds[kind]?.[id] ?? null;
}
function parseTarget(target: string): { source: string; section?: string } {
const [path, section] = target.split(':');
return { source: path, section };
}
function collectRefs(items: PropersBlockItem[]): string[] {
const refs: string[] = [];
for (const it of items) {
if (it.type === 'scriptureRef') refs.push(it.ref);
}
return refs;
}
function hasTextOf(items: PropersBlockItem[], lang: string): boolean {
for (const it of items) {
if (it.type === 'text' && it.lang === lang && it.value.trim()) return true;
}
return false;
}
function resolveSection(
source: string,
sectionName: string,
seen: Set<string>
): PropersBlockItem[] | null {
const key = `${source}:${sectionName}`;
if (seen.has(key)) return null;
seen.add(key);
const entry = lookupEntry(source);
if (!entry) return null;
const topRef = entry.references?.[sectionName];
if (topRef) {
const { source: tgtSource, section: tgtSection } = parseTarget(topRef);
return resolveSection(tgtSource, tgtSection ?? sectionName, seen);
}
const items = entry.sections?.[sectionName];
if (!items || items.length === 0) return null;
for (const it of items) {
if (it.type === 'ref') {
const { source: tgtSource, section: tgtSection } = parseTarget(it.target);
const resolved = resolveSection(tgtSource, tgtSection ?? sectionName, seen);
if (resolved) return resolved;
}
}
return items;
}
export interface ProperRefInfo {
refs: string[];
hasLa: boolean;
hasLocal: boolean;
}
export function getProperRefs(
source: string,
properKey: string,
localLang: 'en' | 'de' | 'la'
): ProperRefInfo {
const aliases = SECTION_ALIASES[properKey] ?? [];
for (const sectionName of aliases) {
const items = resolveSection(source, sectionName, new Set());
if (!items) continue;
return {
refs: collectRefs(items),
hasLa: hasTextOf(items, 'la'),
hasLocal: localLang === 'la' ? true : hasTextOf(items, localLang)
};
}
return { refs: [], hasLa: false, hasLocal: false };
}
export interface RawProperSegment {
refs: string[];
la: string;
local: string;
}
function buildSegments(items: PropersBlockItem[], localLang: string): RawProperSegment[] {
// Raw data lists la texts (with inline scriptureRefs) first, then local
// texts afterwards. Walk the stream separately per language, then zip by
// index so la[i] and local[i] end up in the same segment. Scripture refs
// are attached to the la block that follows them.
const la: string[] = [];
const local: string[] = [];
const refsByIdx = new Map<number, string[]>();
let pendingRefs: string[] = [];
for (const it of items) {
if (it.type === 'scriptureRef') {
pendingRefs.push(it.ref);
} else if (it.type === 'text') {
const val = it.value.trim();
if (!val) continue;
if (it.lang === 'la') {
if (pendingRefs.length) {
refsByIdx.set(la.length, pendingRefs);
pendingRefs = [];
}
la.push(val);
} else if (it.lang === localLang) {
local.push(val);
}
}
}
// Trailing refs with no following la block — attach to last la segment
// so they still render, but only if there's something to attach to.
if (pendingRefs.length && la.length) {
const lastIdx = la.length - 1;
const existing = refsByIdx.get(lastIdx) ?? [];
refsByIdx.set(lastIdx, [...existing, ...pendingRefs]);
}
const count = Math.max(la.length, local.length);
const segs: RawProperSegment[] = [];
for (let i = 0; i < count; i++) {
segs.push({
refs: refsByIdx.get(i) ?? [],
la: la[i] ?? '',
local: local[i] ?? ''
});
}
return segs;
}
export function getProperSegments(
source: string,
properKey: string,
localLang: 'en' | 'de' | 'la'
): RawProperSegment[] | null {
const aliases = SECTION_ALIASES[properKey] ?? [];
for (const sectionName of aliases) {
const items = resolveSection(source, sectionName, new Set());
if (!items) continue;
return buildSegments(items, localLang);
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
if (!/^\d{2}$/.test(param)) return false;
const n = Number(param);
return n >= 1 && n <= 31;
};
+7
View File
@@ -0,0 +1,7 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
if (!/^\d{2}$/.test(param)) return false;
const n = Number(param);
return n >= 1 && n <= 12;
};
+3
View File
@@ -0,0 +1,3 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => /^\d{4}$/.test(param);
@@ -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>> = {