Merge pull request #3 from pupperpowell/rss-feed-fixes

fix(rss): stop future-verse leaks, correct timezone dates, fix feed discovery
This commit is contained in:
George Powell authored and GitHub committed 2026-09-22 12:01:19 -04:00
commit b656fd04c0
5 files changed
+329 -108

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+165
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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;
}
-6
View File
@@ -31,12 +31,6 @@
<svelte:head>
<link rel="icon" href={favicon} />
<link
rel="alternate"
type="application/rss+xml"
title="Bibdle RSS Feed"
href="/feed.xml"
/>
<meta name="description" content="A Wordle-inspired daily Bible game" />
</svelte:head>
+69 -102
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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 `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<description>${escaped}</description>
<content:encoded>${cdata(`<p>${escaped}</p>`)}</content:encoded>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
</item>`;
})
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<description>A daily Bible verse guessing game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
<pubDate>${lastBuildDate}</pubDate>
<generator>SvelteKit</generator>
<ttl>720</ttl>
<atom:link href="${escapeXml(FEED_URL)}" rel="self" type="application/rss+xml" />
<image>
<url>${SITE_URL}/favicon.ico</url>
<title>Bibdle</title>
<link>${SITE_URL}</link>
</image>${items}
</channel>
</rss>`;
@@ -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 });
}
};
}
+94
View File
@@ -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(`<a href="x">&'</a>`)).toBe(
'&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;'
);
});
test('cdata splits embedded CDATA terminators', () => {
expect(cdata('a]]>b')).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
});
test('formatVerseText capitalizes and replaces trailing punctuation', () => {
expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...');
});
});