import type { DailyVerse } from '$lib/server/db/schema'; /** * The feed presents calendar days, so every timestamp is anchored to midnight * in this reference timezone. Using a fixed zone (rather than the server's * local timezone) keeps output stable across UTC dev machines and NY hosts. */ export const FEED_TIMEZONE = 'America/New_York'; /** Number of most-recent verses exposed in the feed. */ export const FEED_ITEM_LIMIT = 30; /** Escape the five XML predefined entities. */ export function escapeXml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * The current calendar date (`YYYY-MM-DD`) in the feed's reference timezone. * Used to cap the feed so future/scheduled verses are never published early. */ export function todayInTimeZone(timeZone: string = FEED_TIMEZONE, now: Date = new Date()): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now); const get = (type: string) => parts.find((part) => part.type === type)?.value ?? ''; return `${get('year')}-${get('month')}-${get('day')}`; } /** * The UTC offset in effect on a given `YYYY-MM-DD` for a timezone, formatted * as `±HH:MM` (e.g. `-04:00` during EDT, `-05:00` during EST). */ export function timeZoneOffset(dateStr: string, timeZone: string = FEED_TIMEZONE): string { const [year, month, day] = dateStr.split('-').map(Number); // Noon UTC is safely inside the calendar day in any US offset, so DST is // resolved correctly regardless of the time of day. const reference = new Date(Date.UTC(year, month - 1, day, 12)); const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }) .formatToParts(reference) .find((part) => part.type === 'timeZoneName')?.value; const offset = (name ?? 'GMT+00:00').replace('GMT', ''); return offset === '' ? '+00:00' : offset; } /** * Format a `YYYY-MM-DD` day as an RFC 822 / RFC 2822 timestamp at midnight in * the feed's reference timezone. Always emits a valid numeric/GMT timestamp * (never a bare, ambiguous abbreviation like `EST`). */ export function formatRFC822(dateStr: string, timeZone: string = FEED_TIMEZONE): string { const offset = timeZoneOffset(dateStr, timeZone); return new Date(`${dateStr}T00:00:00${offset}`).toUTCString(); } /** Human-readable date that does not shift with the server's local timezone. */ export function formatReadableDate(dateStr: string): string { const [year, month, day] = dateStr.split('-').map(Number); return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); } /** Wrap text in a CDATA section, splitting any embedded `]]>` safely. */ export function cdata(text: string): string { return `/g, ']]]]>')}]]>`; } // Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling) export function formatVerseText(text: string): string { let formatted = text; // Handle unbalanced opening/closing punctuation (from Imposter.svelte) const pairs: [string, string][] = [ ['(', ')'], ['[', ']'], ['{', '}'], ['"', '"'], ["'", "'"], ['\u201C', '\u201D'], // " " ['\u2018', '\u2019'] // ' ' ]; // Check if text starts with opening punctuation without closing for (const [open, close] of pairs) { if (formatted.startsWith(open) && !formatted.includes(close)) { formatted += '...' + close; break; } } // Check if text ends with closing punctuation without opening for (const [open, close] of pairs) { if (formatted.endsWith(close) && !formatted.includes(open)) { formatted = open + '...' + formatted; break; } } // Check if text contains unbalanced opening quotes (not at start) without closing for (const [open, close] of pairs) { const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length; const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length; if (openCount > closeCount) { formatted += close; break; } } // Capitalize first letter if lowercase (from VerseDisplay.svelte) formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase()); // Replace trailing punctuation with ellipsis // Preserve closing quotes/brackets that may have been added formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1'); return formatted; } /** * Content fingerprint used as a strong ETag. Any change to the included rows * (new day, edited verse text, changed reference) produces a new hash, so * cached clients revalidate correctly. */ export function feedFingerprint( verses: Pick[] ): string { let hash = 5381; for (const verse of verses) { const row = `${verse.date}\u0000${verse.reference}\u0000${verse.verseText}`; for (let i = 0; i < row.length; i++) { hash = ((hash << 5) + hash + row.charCodeAt(i)) | 0; } } return (hash >>> 0).toString(16); } /** * The most recent `createdAt` across the given rows, used for `Last-Modified`. * Falls back to `fallback` when no row carries a timestamp. */ export function latestModification( verses: Pick[], fallback: Date ): Date { return verses.reduce((latest, verse) => { if (!verse.createdAt) return latest; return !latest || verse.createdAt > latest ? verse.createdAt : latest; }, null) ?? fallback; }