Fetch BSB passage text from Bolls.life API in study lesson view; v1.1.35

Adds /api/bible-passage server route that proxies the Berean Standard Bible
from Bolls.life per-verse, with in-memory caching. Study lesson scripture
block now displays live BSB text instead of static passageText. Adds
bibleBook field to StudyProgram so admins can set the book name explicitly
when the study slug doesn't match (e.g. a study titled "ephesians-part-2").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-10 10:12:25 -04:00
parent 6acc3f1d04
commit 9e602e29ec
8 changed files with 117 additions and 5 deletions
+3 -2
View File
@@ -9,8 +9,8 @@
}, },
"timeOnPage": { "timeOnPage": {
"/": { "/": {
"totalSeconds": 9, "totalSeconds": 51,
"count": 2 "count": 4
}, },
"/episodes": { "/episodes": {
"totalSeconds": 318967, "totalSeconds": 318967,
@@ -22,6 +22,7 @@
} }
}, },
"outboundClicks": {}, "outboundClicks": {},
"linkClicks": {},
"utmSources": {}, "utmSources": {},
"searchQueries": {}, "searchQueries": {},
"notFound": {}, "notFound": {},
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"users": {}, "users": {},
"updatedAt": "2026-08-10T12:37:17.805Z" "updatedAt": "2026-08-10T14:07:00.476Z"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.34", "version": "1.1.35",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -63,6 +63,7 @@ import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerFeed } from './server/routes/feed.js' import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
import { register as registerPublic } from './server/routes/public.js' import { register as registerPublic } from './server/routes/public.js'
const app = express() const app = express()
@@ -110,6 +111,7 @@ registerDownloads(app)
registerEpisodes(app) registerEpisodes(app)
registerFeed(app) registerFeed(app)
registerQrCodes(app) registerQrCodes(app)
registerBiblePassage(app)
// Hit-counting middleware (must come before public routes) // Hit-counting middleware (must come before public routes)
app.use((req, res, next) => { app.use((req, res, next) => {
+76
View File
@@ -0,0 +1,76 @@
const passageCache = new Map()
const BOLLS_BOOK_MAP = {
genesis: 1, exodus: 2, leviticus: 3, numbers: 4, deuteronomy: 5,
joshua: 6, judges: 7, ruth: 8, '1samuel': 9, '2samuel': 10,
'1kings': 11, '2kings': 12, '1chronicles': 13, '2chronicles': 14,
ezra: 15, nehemiah: 16, esther: 17, job: 18, psalms: 19, proverbs: 20,
ecclesiastes: 21, songofsolomon: 22, isaiah: 23, jeremiah: 24,
lamentations: 25, ezekiel: 26, daniel: 27, hosea: 28, joel: 29,
amos: 30, obadiah: 31, jonah: 32, micah: 33, nahum: 34, habakkuk: 35,
zephaniah: 36, haggai: 37, zechariah: 38, malachi: 39,
matthew: 40, mark: 41, luke: 42, john: 43, acts: 44,
romans: 45, '1corinthians': 46, '2corinthians': 47, galatians: 48,
ephesians: 49, philippians: 50, colossians: 51, '1thessalonians': 52,
'2thessalonians': 53, '1timothy': 54, '2timothy': 55, titus: 56,
philemon: 57, hebrews: 58, james: 59, '1peter': 60, '2peter': 61,
'1john': 62, '2john': 63, '3john': 64, jude: 65, revelation: 66,
}
function parseReference(reference) {
const match = reference.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
return {
chapter: parseInt(match[1], 10),
verseStart: parseInt(match[2], 10),
verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10),
}
}
export function register(app) {
app.get('/api/bible-passage', async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
const bookNum = BOLLS_BOOK_MAP[book]
if (!bookNum) {
res.status(400).json({ message: 'Unknown book.' })
return
}
const parsed = parseReference(reference)
if (!parsed) {
res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
return
}
try {
const verseNums = []
for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v)
const results = await Promise.all(
verseNums.map(v =>
fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
)
)
if (results.length === 0) {
res.status(404).json({ message: 'Passage not found.' })
return
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
passageCache.set(cacheKey, text)
res.json({ text })
} catch {
res.status(502).json({ message: 'Unable to fetch passage right now.' })
}
})
}
+5
View File
@@ -4285,6 +4285,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<label htmlFor={`study-slug-${study.id}`}>Slug</label> <label htmlFor={`study-slug-${study.id}`}>Slug</label>
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} /> <input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div> </div>
<div className="admin-field">
<label htmlFor={`study-bible-book-${study.id}`}>Bible Book (for passage lookup)</label>
<input id={`study-bible-book-${study.id}`} type="text" value={study.bibleBook ?? ''} placeholder="colossians (leave blank to use slug)" onChange={e => updateStudyProgram(study.id, 'bibleBook', e.target.value.trim().toLowerCase().replace(/\s+/g, ''))} />
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Lowercase book name used to fetch BSB passage text. E.g. colossians, ephesians, 1corinthians. Leave blank if the slug matches the book name.</p>
</div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor={`study-status-${study.id}`}>Status</label> <label htmlFor={`study-status-${study.id}`}>Status</label>
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}> <select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
+28 -1
View File
@@ -1506,6 +1506,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1') const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('') const [autoSaveMsg, setAutoSaveMsg] = useState('')
const [_checkpointReflection, _setCheckpointReflection] = useState('') const [_checkpointReflection, _setCheckpointReflection] = useState('')
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
const lessonAudioEmbedUrl = useMemo(() => { const lessonAudioEmbedUrl = useMemo(() => {
@@ -1546,6 +1548,21 @@ export function ColossiansStudySectionPage({ content }: Props) {
} }
}, []) }, [])
useEffect(() => {
if (!section?.reference || !currentStudySlug) return
let cancelled = false
setBsbPassage(null)
setBsbPassageLoading(true)
const bookKey = (study?.bibleBook?.trim() || currentStudySlug).toLowerCase()
const params = new URLSearchParams({ book: bookKey, reference: section.reference })
fetch(`/api/bible-passage?${params}`)
.then(r => r.json() as Promise<{ text?: string }>)
.then(data => { if (!cancelled && data.text) setBsbPassage(data.text) })
.catch(() => {})
.finally(() => { if (!cancelled) setBsbPassageLoading(false) })
return () => { cancelled = true }
}, [section?.id, section?.reference, currentStudySlug, study?.bibleBook])
useEffect(() => { useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
let cancelled = false let cancelled = false
@@ -1908,7 +1925,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
>✎</button> >✎</button>
)} )}
<h2>Scripture Text</h2> <h2>Scripture Text</h2>
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p> {bsbPassageLoading
? <p className="study-detail-copy" style={{ opacity: 0.5 }}>Loading passage…</p>
: (bsbPassage ?? section.passageText)
? (bsbPassage ?? section.passageText).split('\n\n').map((para, i) => (
<p key={i} className="study-detail-copy study-detail-copy--scripture">
{para.split('\n').map((line, j, arr) => j < arr.length - 1 ? <>{line}<br /></> : line)}
</p>
))
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
}
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>}
{isEnrolled && openAnchor === 'scripture' && ( {isEnrolled && openAnchor === 'scripture' && (
<NoteCard <NoteCard
anchorLabel="Scripture Text" anchorLabel="Scripture Text"
+1
View File
@@ -9,6 +9,7 @@ export interface StudyProgram {
slug: string slug: string
title: string title: string
description: string description: string
bibleBook?: string
homepageEyebrow?: string homepageEyebrow?: string
showOnHomepage?: boolean showOnHomepage?: boolean
showNewTag?: boolean showNewTag?: boolean