From 6ed29ea52776092c660f804c0e36463df62ae642 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 7 Sep 2026 09:20:12 -0400 Subject: [PATCH] Rebuild Scripture Index as a true cross-content reference index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously it just grouped study lessons under each study (essentially another list of lessons) and ignored podcast episodes entirely. Now it parses every reference from studies and episode show notes, merges entries that point at the same verse(s) into one row with links to all sources, and groups rows by book in canonical Bible order. References that don't parse (e.g. whole-chapter refs) fall into an "Other References" section instead of being dropped. Also fixes the reference passed to the index for studies without a bibleBook set — it was falling back to the study's marketing title instead of its slug, producing unparseable references. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J --- server/routes/public.js | 47 +++++-- src/App.css | 15 +-- src/App.tsx | 277 ++++++++++++++++++++++++++++++---------- 3 files changed, 254 insertions(+), 85 deletions(-) diff --git a/server/routes/public.js b/server/routes/public.js index bed6cfa..6e547dd 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -159,23 +159,44 @@ export function register(app) { `) }) - // Scripture index — all released study lessons with their passage references + // Scripture index — every passage covered across released study lessons and podcast episodes app.get('/api/public/scripture-index', (_req, res) => { const content = state.cachedSiteContent const allStudies = Array.isArray(content?.studies) ? content.studies : [] + const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : [] const cutoff = new Date() - const studies = allStudies - .filter(s => s.slug && Array.isArray(s.sections)) - .map(s => ({ - slug: s.slug, - title: s.title || s.slug, - entries: s.sections - .filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff) - .map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference })) - .sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })), - })) - .filter(s => s.entries.length > 0) - res.json({ studies }) + const entries = [] + + for (const s of allStudies) { + if (!s.slug || !Array.isArray(s.sections)) continue + const bookName = s.bibleBook || s.slug + for (const sec of s.sections) { + if (!sec.id || !sec.reference || !sec.releasedAt) continue + if (new Date(sec.releasedAt) > cutoff) continue + entries.push({ + reference: `${bookName} ${sec.reference}`, + sourceType: 'study', + sourceTitle: s.title || s.slug, + sectionTitle: sec.title || '', + url: `/study/${s.slug}/${sec.id}`, + }) + } + } + + for (const ep of allEpisodes) { + if (!ep.id || !Array.isArray(ep.showNotesScripture)) continue + for (const ref of ep.showNotesScripture) { + if (!ref || !ref.trim()) continue + entries.push({ + reference: ref.trim(), + sourceType: 'episode', + sourceTitle: ep.episodeNumber ? `Episode ${ep.episodeNumber}: ${ep.title}` : (ep.title || 'Episode'), + url: `/episodes/${ep.id}`, + }) + } + } + + res.json({ entries }) }) // SPA static files and fallback diff --git a/src/App.css b/src/App.css index 5d3f188..0193bba 100644 --- a/src/App.css +++ b/src/App.css @@ -12531,19 +12531,18 @@ .scripture-study-group { margin-bottom: 4rem; } .scripture-study-title { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 1.6rem; font-weight: 600; color: var(--brand-gold, #c9a84c); margin: 0 0 0; padding-bottom: 0.75rem; border-bottom: 2px solid var(--brand-gold, #c9a84c); } .scripture-entry-table { display: flex; flex-direction: column; } -.scripture-entry-row { display: grid; grid-template-columns: 130px 1fr 20px; gap: 0 1.5rem; align-items: start; padding: 1rem 0.75rem; border-bottom: 1px solid var(--brand-border, #2a2518); text-decoration: none; color: inherit; transition: background 0.15s; border-radius: 4px; } -.scripture-entry-row:hover { background: rgba(201,168,76,0.06); } +.scripture-entry-row { display: grid; grid-template-columns: 130px 1fr; gap: 0 1.5rem; align-items: start; padding: 1rem 0.75rem; border-bottom: 1px solid var(--brand-border, #2a2518); border-radius: 4px; } .scripture-entry-ref { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 1rem; font-weight: 700; color: var(--brand-gold, #c9a84c); white-space: nowrap; padding-top: 0.1rem; } -.scripture-entry-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; } +.scripture-entry-body { display: flex; flex-direction: column; gap: 0.4rem; min-width: 0; } .scripture-entry-verse { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 0.925rem; line-height: 1.55; color: var(--brand-warm-white, #f0ead8); font-style: italic; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.scripture-entry-lesson { font-size: 0.75rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--brand-muted, #b0a48c); margin-top: 0.15rem; } -.scripture-entry-arrow { color: var(--brand-gold, #c9a84c); opacity: 0.35; font-size: 1rem; padding-top: 0.1rem; transition: opacity 0.15s, transform 0.15s; } -.scripture-entry-row:hover .scripture-entry-arrow { opacity: 1; transform: translateX(3px); } +.scripture-entry-sources { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.scripture-source-chip { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--brand-warm-white, #f0ead8); text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid var(--brand-border, #2a2518); border-radius: 999px; transition: background 0.15s, border-color 0.15s; } +.scripture-source-chip:hover { background: rgba(201,168,76,0.1); border-color: var(--brand-gold, #c9a84c); } +.scripture-source-kind { font-size: 0.65rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--brand-gold, #c9a84c); } @media (max-width: 600px) { - .scripture-entry-row { grid-template-columns: 1fr 20px; } + .scripture-entry-row { grid-template-columns: 1fr; } .scripture-entry-ref { grid-column: 1; font-size: 0.9rem; } .scripture-entry-body { grid-column: 1; } - .scripture-entry-arrow { grid-column: 2; grid-row: 1 / 3; align-self: center; } } /* ── Admin status messages ───────────────────────────────────────────────── */ diff --git a/src/App.tsx b/src/App.tsx index 7904cca..b1d29e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import type { ReactElement } from 'react' import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom' import AdminPage from './AdminPage' @@ -1979,28 +1979,83 @@ function ResourcesPage({ content }: { content: SiteContent }) { ) } -const SI_BOOK_MAP: Record = { - gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV', - num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS', - judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '1 samuel': 'SA1', - '2 sam': 'SA2', '2 samuel': 'SA2', '1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2', - '1 chr': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chronicles': 'CH2', - ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST', - job: 'JOB', ps: 'PSA', psa: 'PSA', psalms: 'PSA', psalm: 'PSA', - prov: 'PRO', proverbs: 'PRO', eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', - isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM', - ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS', - joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC', - nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP', - hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL', - matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT', - rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2', - gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP', - col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2', - '1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2', - titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB', - jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2', - '1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV', +const BIBLE_BOOKS: Array<{ id: string; name: string; aliases?: string[] }> = [ + { id: 'GEN', name: 'Genesis', aliases: ['gen'] }, + { id: 'EXO', name: 'Exodus', aliases: ['exo'] }, + { id: 'LEV', name: 'Leviticus', aliases: ['lev'] }, + { id: 'NUM', name: 'Numbers', aliases: ['num'] }, + { id: 'DEU', name: 'Deuteronomy', aliases: ['deut'] }, + { id: 'JOS', name: 'Joshua', aliases: ['josh'] }, + { id: 'JDG', name: 'Judges', aliases: ['judg'] }, + { id: 'RUT', name: 'Ruth' }, + { id: 'SA1', name: '1 Samuel', aliases: ['1 sam'] }, + { id: 'SA2', name: '2 Samuel', aliases: ['2 sam'] }, + { id: 'KI1', name: '1 Kings', aliases: ['1 kgs'] }, + { id: 'KI2', name: '2 Kings', aliases: ['2 kgs'] }, + { id: 'CH1', name: '1 Chronicles', aliases: ['1 chr'] }, + { id: 'CH2', name: '2 Chronicles', aliases: ['2 chr'] }, + { id: 'EZR', name: 'Ezra' }, + { id: 'NEH', name: 'Nehemiah', aliases: ['neh'] }, + { id: 'EST', name: 'Esther', aliases: ['esth'] }, + { id: 'JOB', name: 'Job' }, + { id: 'PSA', name: 'Psalms', aliases: ['ps', 'psa', 'psalm'] }, + { id: 'PRO', name: 'Proverbs', aliases: ['prov'] }, + { id: 'ECC', name: 'Ecclesiastes', aliases: ['eccl'] }, + { id: 'SNG', name: 'Song of Solomon', aliases: ['song'] }, + { id: 'ISA', name: 'Isaiah', aliases: ['isa'] }, + { id: 'JER', name: 'Jeremiah', aliases: ['jer'] }, + { id: 'LAM', name: 'Lamentations', aliases: ['lam'] }, + { id: 'EZK', name: 'Ezekiel', aliases: ['ezek'] }, + { id: 'DAN', name: 'Daniel', aliases: ['dan'] }, + { id: 'HOS', name: 'Hosea', aliases: ['hos'] }, + { id: 'JOL', name: 'Joel' }, + { id: 'AMO', name: 'Amos' }, + { id: 'OBA', name: 'Obadiah', aliases: ['obad'] }, + { id: 'JNA', name: 'Jonah' }, + { id: 'MIC', name: 'Micah', aliases: ['mic'] }, + { id: 'NAH', name: 'Nahum', aliases: ['nah'] }, + { id: 'HAB', name: 'Habakkuk', aliases: ['hab'] }, + { id: 'ZEP', name: 'Zephaniah', aliases: ['zeph'] }, + { id: 'HAG', name: 'Haggai', aliases: ['hag'] }, + { id: 'ZEC', name: 'Zechariah', aliases: ['zech'] }, + { id: 'MAL', name: 'Malachi', aliases: ['mal'] }, + { id: 'MAT', name: 'Matthew', aliases: ['matt'] }, + { id: 'MRK', name: 'Mark' }, + { id: 'LUK', name: 'Luke' }, + { id: 'JHN', name: 'John' }, + { id: 'ACT', name: 'Acts' }, + { id: 'ROM', name: 'Romans', aliases: ['rom'] }, + { id: 'CO1', name: '1 Corinthians', aliases: ['1 cor'] }, + { id: 'CO2', name: '2 Corinthians', aliases: ['2 cor'] }, + { id: 'GAL', name: 'Galatians', aliases: ['gal'] }, + { id: 'EPH', name: 'Ephesians', aliases: ['eph'] }, + { id: 'PHP', name: 'Philippians', aliases: ['phil'] }, + { id: 'COL', name: 'Colossians', aliases: ['col'] }, + { id: 'TH1', name: '1 Thessalonians', aliases: ['1 thess'] }, + { id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] }, + { id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] }, + { id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] }, + { id: 'TIT', name: 'Titus' }, + { id: 'PHM', name: 'Philemon', aliases: ['philem'] }, + { id: 'HEB', name: 'Hebrews', aliases: ['heb'] }, + { id: 'JAM', name: 'James', aliases: ['jas'] }, + { id: 'PE1', name: '1 Peter', aliases: ['1 pet'] }, + { id: 'PE2', name: '2 Peter', aliases: ['2 pet'] }, + { id: 'JO1', name: '1 John' }, + { id: 'JO2', name: '2 John' }, + { id: 'JO3', name: '3 John' }, + { id: 'JDE', name: 'Jude', aliases: ['jude'] }, + { id: 'REV', name: 'Revelation', aliases: ['rev'] }, +] + +const SI_BOOK_MAP: Record = {} +const SI_BOOK_NAME: Record = {} +const SI_BOOK_ORDER: string[] = [] +for (const b of BIBLE_BOOKS) { + SI_BOOK_MAP[b.name.toLowerCase()] = b.id + for (const alias of b.aliases ?? []) SI_BOOK_MAP[alias] = b.id + SI_BOOK_NAME[b.id] = b.name + SI_BOOK_ORDER.push(b.id) } function parseSIRef(ref: string) { @@ -2024,86 +2079,180 @@ function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEn return texts.join(' ') } +interface ScriptureIndexEntry { + reference: string + sourceType: 'study' | 'episode' + sourceTitle: string + sectionTitle?: string + url: string +} + +interface ScriptureSource { type: 'study' | 'episode'; label: string; url: string } + +interface ScriptureRow { + key: string + displayRef: string + bookId: string | null + chapter: number + verseStart: number + verseEnd: number + sources: ScriptureSource[] +} + +interface ScriptureBookGroup { bookId: string; bookName: string; rows: ScriptureRow[] } + function ScriptureIndexPage({ content }: { content: SiteContent }) { const siteTitle = content.seo?.title || DEFAULTS.seo.title usePageMeta( `Scripture Index | ${siteTitle}`, - 'Every passage covered in our studies, organized by reference. Find the lesson for any verse.', + 'Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse.', ) - interface ScriptureEntry { sectionId: string; title: string; reference: string } - interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] } - const [studies, setStudies] = useState([]) + const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [verseTexts, setVerseTexts] = useState>({}) useEffect(() => { fetch('/api/public/scripture-index') .then(r => r.json()) - .then(d => { if (Array.isArray(d.studies)) setStudies(d.studies) }) + .then(d => { if (Array.isArray(d.entries)) setEntries(d.entries) }) .catch(() => {}) .finally(() => setLoading(false)) }, []) + // Merge entries that share the same reference (a study lesson and an episode can both + // cover the same passage) into one row, then group rows by book in canonical Bible order. + const { bookGroups, otherRows } = useMemo(() => { + const rowMap = new Map() + const order: string[] = [] + for (const entry of entries) { + const parsed = parseSIRef(entry.reference) + const source: ScriptureSource = { + type: entry.sourceType, + url: entry.url, + label: entry.sourceType === 'study' + ? `${entry.sourceTitle}${entry.sectionTitle ? ` — ${entry.sectionTitle}` : ''}` + : entry.sourceTitle, + } + const key = parsed + ? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.verseEnd}` + : `other-${entry.reference.trim().toLowerCase()}` + let row = rowMap.get(key) + if (!row) { + row = { + key, + displayRef: parsed + ? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}` + : entry.reference.trim(), + bookId: parsed?.bookId ?? null, + chapter: parsed?.chapter ?? 0, + verseStart: parsed?.verseStart ?? 0, + verseEnd: parsed?.verseEnd ?? 0, + sources: [], + } + rowMap.set(key, row) + order.push(key) + } + if (!row.sources.some(s => s.url === source.url && s.label === source.label)) row.sources.push(source) + } + + const groupsByBook = new Map() + const other: ScriptureRow[] = [] + for (const k of order) { + const row = rowMap.get(k)! + if (!row.bookId) { other.push(row); continue } + if (!groupsByBook.has(row.bookId)) groupsByBook.set(row.bookId, []) + groupsByBook.get(row.bookId)!.push(row) + } + const groups: ScriptureBookGroup[] = SI_BOOK_ORDER + .filter(id => groupsByBook.has(id)) + .map(id => ({ + bookId: id, + bookName: SI_BOOK_NAME[id], + rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart), + })) + other.sort((a, b) => a.displayRef.localeCompare(b.displayRef)) + return { bookGroups: groups, otherRows: other } + }, [entries]) + useEffect(() => { - if (studies.length === 0) return - // Group entries by chapter to minimize API calls + if (bookGroups.length === 0) return + // Group rows by chapter to minimize API calls const chapterMap = new Map>() - for (const study of studies) { - for (const entry of study.entries) { - const parsed = parseSIRef(entry.reference) - if (!parsed) continue - const ck = `${parsed.bookId}/${parsed.chapter}` + for (const group of bookGroups) { + for (const row of group.rows) { + const ck = `${group.bookId}/${row.chapter}` if (!chapterMap.has(ck)) chapterMap.set(ck, []) - chapterMap.get(ck)!.push({ key: `${study.slug}/${entry.sectionId}`, verseStart: parsed.verseStart, verseEnd: parsed.verseEnd }) + chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd: row.verseEnd }) } } - for (const [ck, entries] of chapterMap) { + for (const [ck, rows] of chapterMap) { const [bookId, chapter] = ck.split('/') fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`) .then(r => r.ok ? r.json() : Promise.reject()) .then((data: { chapter: { content: HellaoContent } }) => { const chContent = data?.chapter?.content ?? [] const updates: Record = {} - for (const e of entries) { - const text = extractVerseRange(chContent, e.verseStart, e.verseEnd) - if (text) updates[e.key] = text + for (const r of rows) { + const text = extractVerseRange(chContent, r.verseStart, r.verseEnd) + if (text) updates[r.key] = text } setVerseTexts(prev => ({ ...prev, ...updates })) }) .catch(() => {}) } - }, [studies]) + }, [bookGroups]) + + const hasContent = bookGroups.length > 0 || otherRows.length > 0 + + function renderRow(row: ScriptureRow) { + return ( +
+ {row.displayRef} + + {verseTexts[row.key] && "{verseTexts[row.key]}"} + + {row.sources.map((s, i) => ( + + {s.type === 'episode' ? 'Episode' : 'Study'} + {s.label} + + ))} + + +
+ ) + } return (
- +
{loading ? (

Loading…

- ) : studies.length === 0 ? ( -

No study lessons are published yet.

- ) : studies.map(study => ( -
-

{study.title}

-
- {study.entries.map(entry => { - const verse = verseTexts[`${study.slug}/${entry.sectionId}`] - return ( - - {entry.reference} - - {verse && "{verse}"} - {entry.title && {entry.title}} - - - - ) - })} -
-
- ))} + ) : !hasContent ? ( +

No Scripture references are published yet.

+ ) : ( + <> + {bookGroups.map(group => ( +
+

{group.bookName}

+
+ {group.rows.map(renderRow)} +
+
+ ))} + {otherRows.length > 0 && ( +
+

Other References

+
+ {otherRows.map(renderRow)} +
+
+ )} + + )}