From f2f40dcd2d7b519308117760091bd3340ac700ff Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 14 Apr 2026 22:41:17 +0200 Subject: [PATCH] feat(faith): render 1962 Mass propers with scripture refs and Bible fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 4 +- src/lib/server/bibleRefLatin.ts | 121 +++++++++++ src/lib/server/romcal1962Refs.ts | 201 ++++++++++++++++++ src/params/calendarDay.ts | 7 + src/params/calendarMonth.ts | 7 + src/params/calendarYear.ts | 3 + .../[calendar=calendarLang]/+page.server.ts | 13 ++ .../[[dd=calendarDay]]}/+page.server.ts | 154 +++++++++++--- .../[[dd=calendarDay]]}/+page.svelte | 171 +++++++++++---- .../[calendar=calendarLang]/calendarI18n.ts | 9 +- 10 files changed, 618 insertions(+), 72 deletions(-) create mode 100644 src/lib/server/bibleRefLatin.ts create mode 100644 src/lib/server/romcal1962Refs.ts create mode 100644 src/params/calendarDay.ts create mode 100644 src/params/calendarMonth.ts create mode 100644 src/params/calendarYear.ts create mode 100644 src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts rename src/routes/[faithLang=faithLang]/[calendar=calendarLang]/{[[year=calendarRite]] => [rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]}/+page.server.ts (67%) rename src/routes/[faithLang=faithLang]/[calendar=calendarLang]/{[[year=calendarRite]] => [rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]}/+page.svelte (84%) diff --git a/package.json b/package.json index bb5588e8..293149e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.33.0", + "version": "1.34.0", "private": true, "type": "module", "scripts": { @@ -20,6 +20,8 @@ "test:e2e:docker:down": "docker compose -f docker-compose.test.yml down -v", "test:e2e:docker": "docker compose -f docker-compose.test.yml up -d && playwright test; docker compose -f docker-compose.test.yml down -v", "test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'", + "deploy": "bash scripts/deploy.sh", + "deploy:dry": "bash scripts/deploy.sh --dry-run", "tauri": "tauri" }, "packageManager": "pnpm@9.0.0", diff --git a/src/lib/server/bibleRefLatin.ts b/src/lib/server/bibleRefLatin.ts new file mode 100644 index 00000000..538d62ca --- /dev/null +++ b/src/lib/server/bibleRefLatin.ts @@ -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 = { + 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, 113–115, 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}`; +} diff --git a/src/lib/server/romcal1962Refs.ts b/src/lib/server/romcal1962Refs.ts new file mode 100644 index 00000000..a7fd9be5 --- /dev/null +++ b/src/lib/server/romcal1962Refs.ts @@ -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; + sections?: Record; +} + +const require = createRequire(import.meta.url); + +function loadJson(name: 'tempora' | 'sancti' | 'commune'): Record { + // 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> = { + tempora: loadJson('tempora'), + sancti: loadJson('sancti'), + commune: loadJson('commune') +}; + +// Proper key → source section name candidates (tried in order) +const SECTION_ALIASES: Record = { + 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 +): 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(); + 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; +} diff --git a/src/params/calendarDay.ts b/src/params/calendarDay.ts new file mode 100644 index 00000000..2017897e --- /dev/null +++ b/src/params/calendarDay.ts @@ -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; +}; diff --git a/src/params/calendarMonth.ts b/src/params/calendarMonth.ts new file mode 100644 index 00000000..90c7e1c9 --- /dev/null +++ b/src/params/calendarMonth.ts @@ -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; +}; diff --git a/src/params/calendarYear.ts b/src/params/calendarYear.ts new file mode 100644 index 00000000..d5e26eae --- /dev/null +++ b/src/params/calendarYear.ts @@ -0,0 +1,3 @@ +import type { ParamMatcher } from '@sveltejs/kit'; + +export const match: ParamMatcher = (param) => /^\d{4}$/.test(param); diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts new file mode 100644 index 00000000..b90179c6 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts @@ -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}`); +}; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts similarity index 67% rename from src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.server.ts rename to src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts index 1d0f70cd..9fb7484c 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.server.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.server.ts @@ -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(); 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 const yearCache = new Map>(); async function getYear(lang: CalendarLang, year: number): Promise> { @@ -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 = { @@ -190,15 +203,93 @@ function textOf(dict: Record | 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 = {}; + 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 { diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte similarity index 84% rename from src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.svelte rename to src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte index a698ba43..eaf645d3 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[[year=calendarRite]]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte @@ -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}`; } @@ -311,13 +313,33 @@

{t1962('propers', lang)}

{#each d.propers as section (section.key)}
-
{properLabel(section.key, lang)}
-
-
{section.la}
- {#if lang !== 'la' && section.local} -
{section.local}
- {/if} +
+ {properLabel(section.key, lang)}
+ {#each section.segments as seg, segIdx (segIdx)} +
+ {#if seg.refs && seg.refs.length} +
+ {#each seg.refs as r (r)} + {r} + {/each} +
+ {/if} + {#if seg.la || seg.local} +
+ {#if lang !== 'la' && seg.local && seg.fromBible} +

{t1962('bibleFallbackNote', lang)}

+ {/if} + {#if seg.la} +
{seg.la}
+ {/if} + {#if lang !== 'la' && seg.local} +
{seg.local}
+ {/if} +
+ {/if} +
+ {/each}
{/each} @@ -327,13 +349,26 @@

{t1962('extraSections', lang)}

{#each d.extraSections as section (section.key)}
-
{properLabel(section.key, lang)}
-
-
{section.la}
- {#if lang !== 'la' && section.local} -
{section.local}
- {/if} +
+ {properLabel(section.key, lang)}
+ {#each section.segments as seg, segIdx (segIdx)} +
+ {#if seg.refs && seg.refs.length} +
+ {#each seg.refs as r (r)} + {r} + {/each} +
+ {/if} +
+
{seg.la}
+ {#if lang !== 'la' && seg.local} +
{seg.local}
+ {/if} +
+
+ {/each}
{/each} @@ -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; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts index 852a823d..463ddc82 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts @@ -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; + return entry[lang] ?? entry.en ?? ''; } const PROPER_LABEL: Record> = {