Rebuild Scripture Index as a true cross-content reference index
Publish Container / docker (push) Successful in 15m2s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-07 09:20:12 -04:00
1 parent 274adcc093
commit 6ed29ea527
3 files changed
+254 -85

No files matched your search

+34 -13
View File
@@ -159,23 +159,44 @@ export function register(app) {
</html>`) </html>`)
}) })
// 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) => { app.get('/api/public/scripture-index', (_req, res) => {
const content = state.cachedSiteContent const content = state.cachedSiteContent
const allStudies = Array.isArray(content?.studies) ? content.studies : [] const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const cutoff = new Date() const cutoff = new Date()
const studies = allStudies const entries = []
.filter(s => s.slug && Array.isArray(s.sections))
.map(s => ({ for (const s of allStudies) {
slug: s.slug, if (!s.slug || !Array.isArray(s.sections)) continue
title: s.title || s.slug, const bookName = s.bibleBook || s.slug
entries: s.sections for (const sec of s.sections) {
.filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff) if (!sec.id || !sec.reference || !sec.releasedAt) continue
.map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference })) if (new Date(sec.releasedAt) > cutoff) continue
.sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })), entries.push({
})) reference: `${bookName} ${sec.reference}`,
.filter(s => s.entries.length > 0) sourceType: 'study',
res.json({ studies }) 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 // SPA static files and fallback
+7 -8
View File
@@ -12531,19 +12531,18 @@
.scripture-study-group { margin-bottom: 4rem; } .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-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-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 { 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-row:hover { background: rgba(201,168,76,0.06); }
.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-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-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-sources { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.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-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-entry-row:hover .scripture-entry-arrow { opacity: 1; transform: translateX(3px); } .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) { @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-ref { grid-column: 1; font-size: 0.9rem; }
.scripture-entry-body { grid-column: 1; } .scripture-entry-body { grid-column: 1; }
.scripture-entry-arrow { grid-column: 2; grid-row: 1 / 3; align-self: center; }
} }
/* ── Admin status messages ───────────────────────────────────────────────── */ /* ── Admin status messages ───────────────────────────────────────────────── */
+213 -64
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom' import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage' import AdminPage from './AdminPage'
@@ -1979,28 +1979,83 @@ function ResourcesPage({ content }: { content: SiteContent }) {
) )
} }
const SI_BOOK_MAP: Record<string, string> = { const BIBLE_BOOKS: Array<{ id: string; name: string; aliases?: string[] }> = [
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV', { id: 'GEN', name: 'Genesis', aliases: ['gen'] },
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS', { id: 'EXO', name: 'Exodus', aliases: ['exo'] },
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '1 samuel': 'SA1', { id: 'LEV', name: 'Leviticus', aliases: ['lev'] },
'2 sam': 'SA2', '2 samuel': 'SA2', '1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2', { id: 'NUM', name: 'Numbers', aliases: ['num'] },
'1 chr': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chronicles': 'CH2', { id: 'DEU', name: 'Deuteronomy', aliases: ['deut'] },
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST', { id: 'JOS', name: 'Joshua', aliases: ['josh'] },
job: 'JOB', ps: 'PSA', psa: 'PSA', psalms: 'PSA', psalm: 'PSA', { id: 'JDG', name: 'Judges', aliases: ['judg'] },
prov: 'PRO', proverbs: 'PRO', eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', { id: 'RUT', name: 'Ruth' },
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM', { id: 'SA1', name: '1 Samuel', aliases: ['1 sam'] },
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS', { id: 'SA2', name: '2 Samuel', aliases: ['2 sam'] },
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC', { id: 'KI1', name: '1 Kings', aliases: ['1 kgs'] },
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP', { id: 'KI2', name: '2 Kings', aliases: ['2 kgs'] },
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL', { id: 'CH1', name: '1 Chronicles', aliases: ['1 chr'] },
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT', { id: 'CH2', name: '2 Chronicles', aliases: ['2 chr'] },
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2', { id: 'EZR', name: 'Ezra' },
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP', { id: 'NEH', name: 'Nehemiah', aliases: ['neh'] },
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2', { id: 'EST', name: 'Esther', aliases: ['esth'] },
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2', { id: 'JOB', name: 'Job' },
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB', { id: 'PSA', name: 'Psalms', aliases: ['ps', 'psa', 'psalm'] },
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2', { id: 'PRO', name: 'Proverbs', aliases: ['prov'] },
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV', { 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<string, string> = {}
const SI_BOOK_NAME: Record<string, string> = {}
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) { function parseSIRef(ref: string) {
@@ -2024,86 +2079,180 @@ function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEn
return texts.join(' ') 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 }) { function ScriptureIndexPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta( usePageMeta(
`Scripture Index | ${siteTitle}`, `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 } const [entries, setEntries] = useState<ScriptureIndexEntry[]>([])
interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] }
const [studies, setStudies] = useState<StudyGroup[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [verseTexts, setVerseTexts] = useState<Record<string, string>>({}) const [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
fetch('/api/public/scripture-index') fetch('/api/public/scripture-index')
.then(r => r.json()) .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(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .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<string, ScriptureRow>()
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<string, ScriptureRow[]>()
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(() => { useEffect(() => {
if (studies.length === 0) return if (bookGroups.length === 0) return
// Group entries by chapter to minimize API calls // Group rows by chapter to minimize API calls
const chapterMap = new Map<string, Array<{ key: string; verseStart: number; verseEnd: number }>>() const chapterMap = new Map<string, Array<{ key: string; verseStart: number; verseEnd: number }>>()
for (const study of studies) { for (const group of bookGroups) {
for (const entry of study.entries) { for (const row of group.rows) {
const parsed = parseSIRef(entry.reference) const ck = `${group.bookId}/${row.chapter}`
if (!parsed) continue
const ck = `${parsed.bookId}/${parsed.chapter}`
if (!chapterMap.has(ck)) chapterMap.set(ck, []) 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('/') const [bookId, chapter] = ck.split('/')
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`) fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => r.ok ? r.json() : Promise.reject()) .then(r => r.ok ? r.json() : Promise.reject())
.then((data: { chapter: { content: HellaoContent } }) => { .then((data: { chapter: { content: HellaoContent } }) => {
const chContent = data?.chapter?.content ?? [] const chContent = data?.chapter?.content ?? []
const updates: Record<string, string> = {} const updates: Record<string, string> = {}
for (const e of entries) { for (const r of rows) {
const text = extractVerseRange(chContent, e.verseStart, e.verseEnd) const text = extractVerseRange(chContent, r.verseStart, r.verseEnd)
if (text) updates[e.key] = text if (text) updates[r.key] = text
} }
setVerseTexts(prev => ({ ...prev, ...updates })) setVerseTexts(prev => ({ ...prev, ...updates }))
}) })
.catch(() => {}) .catch(() => {})
} }
}, [studies]) }, [bookGroups])
const hasContent = bookGroups.length > 0 || otherRows.length > 0
function renderRow(row: ScriptureRow) {
return (
<div key={row.key} className="scripture-entry-row">
<span className="scripture-entry-ref">{row.displayRef}</span>
<span className="scripture-entry-body">
{verseTexts[row.key] && <span className="scripture-entry-verse">"{verseTexts[row.key]}"</span>}
<span className="scripture-entry-sources">
{row.sources.map((s, i) => (
<Link key={i} to={s.url} className={`scripture-source-chip scripture-source-chip--${s.type}`}>
<span className="scripture-source-kind">{s.type === 'episode' ? 'Episode' : 'Study'}</span>
{s.label}
</Link>
))}
</span>
</span>
</div>
)
}
return ( return (
<div className="site"> <div className="site">
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} /> <SiteSearchBar content={content} />
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered in our studies, organized by reference. Find the lesson for any verse." /> <PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
<main className="scripture-index-main page-width"> <main className="scripture-index-main page-width">
{loading ? ( {loading ? (
<p className="scripture-index-note">Loading…</p> <p className="scripture-index-note">Loading…</p>
) : studies.length === 0 ? ( ) : !hasContent ? (
<p className="scripture-index-note">No study lessons are published yet.</p> <p className="scripture-index-note">No Scripture references are published yet.</p>
) : studies.map(study => ( ) : (
<section key={study.slug} className="scripture-study-group"> <>
<h2 className="scripture-study-title">{study.title}</h2> {bookGroups.map(group => (
<div className="scripture-entry-table"> <section key={group.bookId} className="scripture-study-group">
{study.entries.map(entry => { <h2 className="scripture-study-title">{group.bookName}</h2>
const verse = verseTexts[`${study.slug}/${entry.sectionId}`] <div className="scripture-entry-table">
return ( {group.rows.map(renderRow)}
<Link key={entry.sectionId} to={`/study/${study.slug}/${entry.sectionId}`} className="scripture-entry-row"> </div>
<span className="scripture-entry-ref">{entry.reference}</span> </section>
<span className="scripture-entry-body"> ))}
{verse && <span className="scripture-entry-verse">"{verse}"</span>} {otherRows.length > 0 && (
{entry.title && <span className="scripture-entry-lesson">{entry.title}</span>} <section className="scripture-study-group">
</span> <h2 className="scripture-study-title">Other References</h2>
<span className="scripture-entry-arrow" aria-hidden="true">→</span> <div className="scripture-entry-table">
</Link> {otherRows.map(renderRow)}
) </div>
})} </section>
</div> )}
</section> </>
))} )}
</main> </main>
<SiteFooter content={content} /> <SiteFooter content={content} />
<AnalyticsConsentBanner content={content} /> <AnalyticsConsentBanner content={content} />