mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
- Cap /feed.xml at the current America/New_York calendar day so pre-scheduled future verses (and stray 2124 rows) can no longer surface at the top of the feed - Emit DST-aware RFC 822 pubDates (correct -04:00/-05:00 offset) instead of a hardcoded EST offset, and parse calendar days as UTC so titles no longer shift by one day on UTC hosts - Add content-addressed ETag + Last-Modified with If-None-Match / If-Modified-Since conditional handling - Add atom:link rel=self, content:encoded, channel pubDate/generator/image - Move RSS auto-discovery <link> into app.html: the root route sets ssr=false, so the layout <svelte:head> link never reached the raw HTML and feed readers could not discover /feed.xml - Extract feed helpers into $lib/server/rss and add unit tests
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
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, '"')
|
|
.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 `<![CDATA[${text.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
|
}
|
|
|
|
// 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<DailyVerse, 'date' | 'reference' | 'verseText'>[]
|
|
): 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<DailyVerse, 'createdAt'>[],
|
|
fallback: Date
|
|
): Date {
|
|
return verses.reduce<Date | null>((latest, verse) => {
|
|
if (!verse.createdAt) return latest;
|
|
return !latest || verse.createdAt > latest ? verse.createdAt : latest;
|
|
}, null) ?? fallback;
|
|
} |