mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bff35323 |
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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, '"')
|
||||
.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;
|
||||
}
|
||||
@@ -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
@@ -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, '"')
|
||||
.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 `
|
||||
<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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
'<a href="x">&'</a>'
|
||||
);
|
||||
});
|
||||
|
||||
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...');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user