mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 13: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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<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" />
|
<meta name="description" content="A Wordle-inspired daily Bible game" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
+69
-102
@@ -1,133 +1,99 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses } from '$lib/server/db/schema';
|
import { dailyVerses } from '$lib/server/db/schema';
|
||||||
import { desc } from 'drizzle-orm';
|
import { desc, lte } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
// Helper: Escape XML special characters
|
FEED_ITEM_LIMIT,
|
||||||
function escapeXml(text: string): string {
|
cdata,
|
||||||
return text
|
escapeXml,
|
||||||
.replace(/&/g, '&')
|
feedFingerprint,
|
||||||
.replace(/</g, '<')
|
formatRFC822,
|
||||||
.replace(/>/g, '>')
|
formatReadableDate,
|
||||||
.replace(/"/g, '"')
|
formatVerseText,
|
||||||
.replace(/'/g, ''');
|
latestModification,
|
||||||
}
|
todayInTimeZone
|
||||||
|
} from '$lib/server/rss';
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ request }) => {
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
try {
|
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
|
const verses = await db
|
||||||
.select()
|
.select()
|
||||||
.from(dailyVerses)
|
.from(dailyVerses)
|
||||||
|
.where(lte(dailyVerses.date, today))
|
||||||
.orderBy(desc(dailyVerses.date))
|
.orderBy(desc(dailyVerses.date))
|
||||||
.limit(30);
|
.limit(FEED_ITEM_LIMIT);
|
||||||
|
|
||||||
// Generate ETag based on latest verse date
|
// Content-addressed ETag + Last-Modified let aggregators revalidate
|
||||||
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
// 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
|
const notModified =
|
||||||
if (request.headers.get('If-None-Match') === etag) {
|
request.headers.get('If-None-Match') === etag ||
|
||||||
return new Response(null, { status: 304 });
|
(() => {
|
||||||
|
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 lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||||
|
|
||||||
const items = verses
|
const items = verses
|
||||||
.map(
|
.map((verse) => {
|
||||||
(verse) => `
|
const body = formatVerseText(verse.verseText);
|
||||||
|
const escaped = escapeXml(body);
|
||||||
|
return `
|
||||||
<item>
|
<item>
|
||||||
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
<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>
|
<link>${SITE_URL}</link>
|
||||||
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||||
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||||
</item>`
|
</item>`;
|
||||||
)
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
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>
|
<channel>
|
||||||
<title>Bibdle</title>
|
<title>Bibdle</title>
|
||||||
<link>${SITE_URL}</link>
|
<link>${SITE_URL}</link>
|
||||||
<description>A daily Bible game</description>
|
<description>A daily Bible verse guessing game</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
<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>
|
</channel>
|
||||||
</rss>`;
|
</rss>`;
|
||||||
|
|
||||||
@@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||||
ETag: etag
|
ETag: etag,
|
||||||
|
'Last-Modified': lastModified
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('RSS feed generation error:', error);
|
console.error('RSS feed generation error:', error);
|
||||||
return new Response('Internal Server Error', { status: 500 });
|
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