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