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:
@@ -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, 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
+125
-29
@@ -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 {
|
||||
+131
-40
@@ -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>> = {
|
||||
|
||||
Reference in New Issue
Block a user