diff --git a/src/app.html b/src/app.html index 27b1cc0..8093723 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,7 @@ + %sveltekit.head% diff --git a/src/lib/server/rss.ts b/src/lib/server/rss.ts new file mode 100644 index 0000000..36c5a7a --- /dev/null +++ b/src/lib/server/rss.ts @@ -0,0 +1,165 @@ +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; +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dfdcc48..d0ad2be 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -31,12 +31,6 @@ - diff --git a/src/routes/feed.xml/+server.ts b/src/routes/feed.xml/+server.ts index a92c22d..0d852ea 100644 --- a/src/routes/feed.xml/+server.ts +++ b/src/routes/feed.xml/+server.ts @@ -1,133 +1,99 @@ import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; import { dailyVerses } from '$lib/server/db/schema'; -import { desc } from 'drizzle-orm'; - -// Helper: Escape XML special characters -function escapeXml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// Helper: Format YYYY-MM-DD to RFC 822 date string -function formatRFC822(dateStr: string): string { - // Parse date in America/New_York timezone (EST/EDT) - // Assuming midnight ET - const date = new Date(dateStr + 'T00:00:00-05:00'); - return date.toUTCString().replace('GMT', 'EST'); -} - -// Helper: Format YYYY-MM-DD to readable date -function formatReadableDate(dateStr: string): string { - const date = new Date(dateStr + 'T00:00:00'); - return date.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - timeZone: 'America/New_York' - }); -} - -// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling) -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; -} +import { desc, lte } from 'drizzle-orm'; +import { + FEED_ITEM_LIMIT, + cdata, + escapeXml, + feedFingerprint, + formatRFC822, + formatReadableDate, + formatVerseText, + latestModification, + todayInTimeZone +} from '$lib/server/rss'; export const GET: RequestHandler = async ({ request }) => { try { - // Query last 30 verses, ordered by date descending + const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com'; + const FEED_URL = `${SITE_URL}/feed.xml`; + const today = todayInTimeZone(); + + // Only publish verses whose calendar day has arrived. Pre-scheduled + // future verses (from the submission scheduling scan) and any stray + // future-dated rows must never leak into the feed early. const verses = await db .select() .from(dailyVerses) + .where(lte(dailyVerses.date, today)) .orderBy(desc(dailyVerses.date)) - .limit(30); + .limit(FEED_ITEM_LIMIT); - // Generate ETag based on latest verse date - const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"'; + // Content-addressed ETag + Last-Modified let aggregators revalidate + // cheaply with If-None-Match / If-Modified-Since. + const etag = `"bibdle-feed-${feedFingerprint(verses)}"`; + const lastModifiedDate = latestModification( + verses, + new Date(`${today}T00:00:00Z`) + ); + const lastModified = lastModifiedDate.toUTCString(); - // Check if client has cached version - if (request.headers.get('If-None-Match') === etag) { - return new Response(null, { status: 304 }); + const notModified = + request.headers.get('If-None-Match') === etag || + (() => { + const since = request.headers.get('If-Modified-Since'); + if (!since) return false; + const sinceMs = Date.parse(since); + return !Number.isNaN(sinceMs) && lastModifiedDate.getTime() <= sinceMs; + })(); + + if (notModified) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + 'Last-Modified': lastModified, + 'Cache-Control': 'public, max-age=3600, s-maxage=3600' + } + }); } - // Get site URL from environment or use default - const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com'; - - // Build RSS XML const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString(); const items = verses - .map( - (verse) => ` + .map((verse) => { + const body = formatVerseText(verse.verseText); + const escaped = escapeXml(body); + return ` Bibdle verse for ${formatReadableDate(verse.date)} - ${escapeXml(formatVerseText(verse.verseText))} + ${escaped} + ${cdata(`

${escaped}

`)}
${SITE_URL} bibdle-verse-${verse.date} ${formatRFC822(verse.date)} -
` - ) + `; + }) .join(''); const xml = ` - + Bibdle ${SITE_URL} - A daily Bible game + A daily Bible verse guessing game en-us ${lastBuildDate} - 720${items} + ${lastBuildDate} + SvelteKit + 720 + + + ${SITE_URL}/favicon.ico + Bibdle + ${SITE_URL} + ${items} `; @@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600, s-maxage=3600', - ETag: etag + ETag: etag, + 'Last-Modified': lastModified } }); } catch (error) { console.error('RSS feed generation error:', error); return new Response('Internal Server Error', { status: 500 }); } -}; +} \ No newline at end of file diff --git a/tests/rss.test.ts b/tests/rss.test.ts new file mode 100644 index 0000000..84c590a --- /dev/null +++ b/tests/rss.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from 'bun:test'; +import { + cdata, + escapeXml, + feedFingerprint, + formatRFC822, + formatReadableDate, + formatVerseText, + latestModification, + todayInTimeZone +} from '$lib/server/rss'; + +describe('RSS date formatting', () => { + test('formatReadableDate uses the calendar day, independent of server TZ', () => { + expect(formatReadableDate('2026-09-18')).toBe('September 18, 2026'); + expect(formatReadableDate('2026-01-01')).toBe('January 1, 2026'); + }); + + test('formatRFC822 emits a valid GMT timestamp', () => { + expect(formatRFC822('2026-01-15')).toBe('Thu, 15 Jan 2026 05:00:00 GMT'); + expect(formatRFC822('2026-09-18')).toBe('Fri, 18 Sep 2026 04:00:00 GMT'); + }); + + test('formatRFC822 handles daylight saving (EST -05:00 vs EDT -04:00)', () => { + // Winter: midnight New York is 05:00 UTC. + expect(formatRFC822('2026-01-15')).toContain('05:00:00 GMT'); + // Summer: midnight New York is 04:00 UTC. + expect(formatRFC822('2026-07-15')).toContain('04:00:00 GMT'); + // Never a bare, ambiguous abbreviation like EST/EDT. + expect(formatRFC822('2026-07-15')).not.toMatch(/\bE[SD]T\b/); + }); + + test('todayInTimeZone respects the reference timezone', () => { + // 02:00 UTC on the 23rd is still the evening of the 22nd in New York. + expect( + todayInTimeZone('America/New_York', new Date('2026-09-23T02:00:00Z')) + ).toBe('2026-09-22'); + // 04:00 UTC on the 23rd is midnight in New York. + expect( + todayInTimeZone('America/New_York', new Date('2026-09-23T04:00:00Z')) + ).toBe('2026-09-23'); + }); +}); + +describe('RSS content fingerprint', () => { + const row = { date: '2026-09-18', reference: 'Leviticus 19:9-11', verseText: 'a' }; + + test('is stable for identical content', () => { + expect(feedFingerprint([row])).toBe(feedFingerprint([{ ...row }])); + }); + + test('changes when verse text changes', () => { + expect(feedFingerprint([row])).not.toBe(feedFingerprint([{ ...row, verseText: 'b' }])); + }); + + test('changes when the set of days changes', () => { + expect(feedFingerprint([row])).not.toBe( + feedFingerprint([row, { ...row, date: '2026-09-17' }]) + ); + }); +}); + +describe('latestModification', () => { + const fallback = new Date('2026-09-18T00:00:00Z'); + + test('returns the newest createdAt', () => { + const older = new Date('2026-09-16T10:00:00Z'); + const newer = new Date('2026-09-17T10:00:00Z'); + expect( + latestModification([{ createdAt: older }, { createdAt: newer }], fallback) + ).toEqual(newer); + }); + + test('falls back when rows have no timestamp', () => { + expect(latestModification([{ createdAt: null }], fallback)).toEqual(fallback); + expect(latestModification([], fallback)).toEqual(fallback); + }); +}); + +describe('XML serialization helpers', () => { + test('escapeXml escapes all predefined entities', () => { + expect(escapeXml(`&'`)).toBe( + '<a href="x">&'</a>' + ); + }); + + test('cdata splits embedded CDATA terminators', () => { + expect(cdata('a]]>b')).toBe('b]]>'); + }); + + test('formatVerseText capitalizes and replaces trailing punctuation', () => { + expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...'); + }); +}); \ No newline at end of file