Merge pull request #1 from pupperpowell/rss

created rss feeds
This commit is contained in:
George Powell
2026-02-02 02:53:50 -05:00
committed by GitHub
8 changed files with 359 additions and 208 deletions

View File

@@ -5,7 +5,6 @@
"Read(./secrets/**)", "Read(./secrets/**)",
"Read(./config/credentials.json)", "Read(./config/credentials.json)",
"Read(./build)", "Read(./build)",
"Read(./**.xml)",
"Read(./embeddings**)" "Read(./embeddings**)"
] ]
} }

View File

@@ -1,4 +1,5 @@
DATABASE_URL=example.db DATABASE_URL=example.db
PUBLIC_SITE_URL=https://bibdle.com
# nodemailer # nodemailer
SMTP_USERNAME=email@example.com SMTP_USERNAME=email@example.com

3
.gitignore vendored
View File

@@ -28,4 +28,5 @@ vite.config.ts.timestamp-*
llms-* llms-*
embeddings* embeddings*
*.xml *bible.xml
engwebu_usfx.xml

View File

@@ -24143,7 +24143,7 @@
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse> <verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse> <verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse>
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse> <verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse> <verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse> <verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse> <verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse> <verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
@@ -33616,4 +33616,4 @@
</chapter> </chapter>
</book> </book>
</testament> </testament>
</bible> </bible>

View File

@@ -1,241 +1,246 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
interface ImposterData { interface ImposterData {
verses: string[]; verses: string[];
refs: string[]; refs: string[];
imposterIndex: number; imposterIndex: number;
} }
let data: ImposterData | null = null; let data: ImposterData | null = null;
let clicked: boolean[] = []; let clicked: boolean[] = [];
let gameOver = false; let gameOver = false;
let loading = true; let loading = true;
let error: string | null = null; let error: string | null = null;
async function loadGame() { async function loadGame() {
try { try {
const res = await fetch("/api/imposter"); const res = await fetch("/api/imposter");
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
data = (await res.json()) as ImposterData; data = (await res.json()) as ImposterData;
clicked = new Array(data.verses.length).fill(false); clicked = new Array(data.verses.length).fill(false);
gameOver = false; gameOver = false;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Unknown error"; error = e instanceof Error ? e.message : "Unknown error";
} finally { } finally {
loading = false; loading = false;
} }
} }
function handleClick(index: number) { function handleClick(index: number) {
if (gameOver || !data || clicked[index]) return; if (gameOver || !data || clicked[index]) return;
clicked[index] = true; clicked[index] = true;
if (index !== data.imposterIndex) { if (index !== data.imposterIndex) {
clicked[data.imposterIndex] = true; clicked[data.imposterIndex] = true;
} }
gameOver = true; gameOver = true;
} }
function newGame() { function newGame() {
loading = true; loading = true;
error = null; error = null;
data = null; data = null;
loadGame(); loadGame();
} }
onMount(loadGame); onMount(loadGame);
function formatVerse(verse: string): string { function formatVerse(verse: string): string {
let formatted = verse; let formatted = verse;
// Handle unbalanced opening/closing punctuation // Handle unbalanced opening/closing punctuation
const pairs: [string, string][] = [ const pairs: [string, string][] = [
["(", ")"], ["(", ")"],
["[", "]"], ["[", "]"],
["{", "}"], ["{", "}"],
['"', '"'], ['"', '"'],
["'", "'"], ["'", "'"],
["\u201C", "\u201D"], // \u201C ["\u201C", "\u201D"], // \u201C
["\u2018", "\u2019"], // \u2018 ["\u2018", "\u2019"], // \u2018
]; ];
for (const [open, close] of pairs) { for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) { if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += "..." + close; formatted += "..." + close;
break; break;
} }
} }
for (const [open, close] of pairs) { for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) { if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + "..." + formatted; formatted = open + "..." + formatted;
break; break;
} }
} }
if (/^[a-z]/.test(formatted)) { if (/^[a-z]/.test(formatted)) {
formatted = "..." + formatted; formatted = "..." + formatted;
} }
formatted = formatted.replace(/[,:;-—]$/, "..."); // Replace trailing punctuation with ellipsis
return formatted; // Preserve closing quotes/brackets that may have been added
} formatted = formatted.replace(
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
"...$1",
);
return formatted;
}
</script> </script>
<div class="imposter-game"> <div class="imposter-game">
{#if loading} {#if loading}
<p class="loading">Loading verses...</p> <p class="loading">Loading verses...</p>
{:else if error} {:else if error}
<div class="error"> <div class="error">
<p>Error: {error}</p> <p>Error: {error}</p>
<button on:click={newGame}>Retry</button> <button on:click={newGame}>Retry</button>
</div> </div>
{:else if data} {:else if data}
<!-- <div class="instructions"> <!-- <div class="instructions">
<p>Click the verse that doesn't belong (from a different book).</p> <p>Click the verse that doesn't belong (from a different book).</p>
</div> --> </div> -->
<div class="verses"> <div class="verses">
{#each data.verses as verse, i} {#each data.verses as verse, i}
<div class="verse-item"> <div class="verse-item">
<button <button
class="verse-button" class="verse-button"
class:clicked={clicked[i]} class:clicked={clicked[i]}
class:correct={clicked[i] && i === data.imposterIndex} class:correct={clicked[i] && i === data.imposterIndex}
class:wrong={clicked[i] && i !== data.imposterIndex} class:wrong={clicked[i] && i !== data.imposterIndex}
on:click={() => handleClick(i)} on:click={() => handleClick(i)}
disabled={gameOver} disabled={gameOver}
> >
{formatVerse(verse)} {formatVerse(verse)}
</button> </button>
{#if gameOver} {#if gameOver}
<div class="ref">{data.refs[i]}</div> <div class="ref">{data.refs[i]}</div>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{#if gameOver} {#if gameOver}
<div class="result"> <div class="result">
<button on:click={newGame}>New Game</button> <button on:click={newGame}>New Game</button>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
<style> <style>
.imposter-game { .imposter-game {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
padding: 2rem; padding: 2rem;
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
.loading, .loading,
.error { .error {
text-align: center; text-align: center;
} }
.instructions { /*.instructions {
text-align: center; text-align: center;
font-style: italic; font-style: italic;
color: #666; color: #666;
} }*/
.verses { .verses {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem; gap: 1.5rem;
width: 100%; width: 100%;
} }
.verse-item { .verse-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.verse-button { .verse-button {
padding: 1.5rem; padding: 1.5rem;
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.4; line-height: 1.4;
border: 3px solid #ddd; border: 3px solid #ddd;
background: #fafafa; background: #fafafa;
cursor: pointer; cursor: pointer;
border-radius: 12px; border-radius: 12px;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 100px; min-height: 100px;
text-align: left; text-align: left;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.verse-button:hover:not(.clicked):not(:disabled) { .verse-button:hover:not(.clicked):not(:disabled) {
border-color: #007bff; border-color: #007bff;
background: #f8f9ff; background: #f8f9ff;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
} }
.verse-button:disabled { .verse-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
.verse-button.clicked { .verse-button.clicked {
cursor: default; cursor: default;
} }
.correct { .correct {
background: #d4edda !important; background: #d4edda !important;
border-color: #28a745 !important; border-color: #28a745 !important;
color: #155724; color: #155724;
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3); box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
} }
.wrong { .wrong {
background: #f8d7da !important; background: #f8d7da !important;
border-color: #dc3545 !important; border-color: #dc3545 !important;
color: #721c24; color: #721c24;
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3); box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
} }
.ref { .ref {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
color: #555; color: #555;
padding-top: 0.25rem; padding-top: 0.25rem;
} }
.verse-button.correct ~ .ref { .verse-button.correct ~ .ref {
color: #28a745; color: #28a745;
font-weight: bold; font-weight: bold;
} }
.verse-button.wrong ~ .ref { .verse-button.wrong ~ .ref {
color: #dc3545; color: #dc3545;
} }
.result { .result {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.result button, .result button,
.error button { .error button {
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
background: #007bff; background: #007bff;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.result button:hover, .result button:hover,
.error button:hover { .error button:hover {
background: #0056b3; background: #0056b3;
} }
</style> </style>

View File

@@ -7,6 +7,7 @@
<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" />
<script <script
defer defer
src="https://umami.snail.city/script.js" src="https://umami.snail.city/script.js"

View File

@@ -437,7 +437,6 @@
</script> </script>
<svelte:head> <svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title> <title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta <!-- <meta
name="description" name="description"

View File

@@ -0,0 +1,145 @@
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;
}
export const GET: RequestHandler = async ({ request }) => {
try {
// Query last 30 verses, ordered by date descending
const verses = await db
.select()
.from(dailyVerses)
.orderBy(desc(dailyVerses.date))
.limit(30);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Check if client has cached version
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
}
// 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) => `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};