Reader: verse study panel (commentaries, people & places) and NET Bible
Build and Push Docker Image / build-and-push (push) Failing after 20s

Adds a per-verse panel with seven HelloAO commentaries and Theographic
people/places/events, and NET as a fifth Reader translation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 08:57:51 -04:00
1 parent 84a5bb8838
commit 9e8420a1ae
4 files changed
+261 -2

No files matched your search

+5 -2
View File
@@ -7,6 +7,8 @@ import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../uti
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
@@ -120,8 +122,9 @@ export function useBibleReader(currentPage) {
let verses;
let copyright = '';
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
if (translation === 'BSB') {
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
if (free) {
const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
verses = parseBibleChapter(data);
+85
View File
@@ -0,0 +1,85 @@
// Free study data from HelloAO: public-domain commentaries and the Theographic
// people/places/events dataset. Both are keyed by book + chapter, so results are
// cached per chapter for the rest of the visit.
import { useEffect, useState } from 'react';
import { renderVerseContent } from '../utils/bibleUtils.jsx';
const API = 'https://bible.helloao.org/api';
export const COMMENTARIES = [
{ id: 'matthew-henry', name: 'Matthew Henry', credit: 'Matthew Henry Bible Commentary (public domain).' },
{ id: 'john-gill', name: 'John Gill', credit: 'John Gill Bible Commentary (public domain).' },
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown', credit: 'Jamieson-Fausset-Brown Bible Commentary (public domain).' },
{ id: 'tyndale', name: 'Tyndale Study Notes', credit: 'Tyndale Open Study Notes © Tyndale House Foundation, licensed CC BY-SA 4.0.' },
{ id: 'adam-clarke', name: 'Adam Clarke', credit: 'Adam Clarke Bible Commentary (public domain).' },
{ id: 'john-calvin', name: 'John Calvin', credit: "John Calvin's Commentaries (public domain)." },
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)', credit: 'Keil & Delitzsch Old Testament Commentary (public domain).' },
];
const commentaryCache = new Map(); // "id/BOOK/ch" -> Promise<entries | null>
const theographicCache = new Map(); // "BOOK/ch" -> Promise<{people, places, events} | null>
const personCache = new Map(); // id -> Promise<person | null>
const getJson = (url) => fetch(url).then((r) => (r.ok ? r.json() : null)).catch(() => null);
// Resolves to [{ number, text }] sorted by verse, or null when the commentary
// doesn't cover that book/chapter.
export function loadCommentary(id, book, chapter) {
const key = `${id}/${book}/${chapter}`;
if (!commentaryCache.has(key)) {
commentaryCache.set(key, getJson(`${API}/c/${id}/${book}/${chapter}.json`).then((data) => {
const items = data?.chapter?.content;
if (!Array.isArray(items)) return null;
return items
.filter((it) => it?.type === 'verse')
.map((it) => ({ number: it.number, text: renderVerseContent(it.content).replace(/[ \t]+\n/g, '\n').trim() }))
.filter((it) => it.text)
.sort((a, b) => a.number - b.number);
}));
}
return commentaryCache.get(key);
}
export function loadTheographic(book, chapter) {
const key = `${book}/${chapter}`;
if (!theographicCache.has(key)) {
theographicCache.set(key, getJson(`${API}/d/theographic/${book}/${chapter}.json`).then((data) => (
data?.chapter ? { people: data.chapter.people ?? [], places: data.chapter.places ?? [], events: data.chapter.events ?? [] } : null
)));
}
return theographicCache.get(key);
}
export function loadPerson(id) {
if (!personCache.has(id)) {
personCache.set(id, getJson(`${API}/d/theographic/people/${id}.json`).then((d) => d?.person ?? null));
}
return personCache.get(id);
}
// For verse N, the commentary entry that starts at N, or otherwise the closest
// earlier one (several commentaries write on whole passages, not each verse).
export function pickCommentaryEntry(entries, verse) {
if (!entries?.length) return null;
let best = null;
for (const e of entries) {
if (e.number <= verse) best = e;
else break;
}
return best;
}
// Small state wrapper: { status: 'loading' | 'ready' | 'empty', data }.
export function useAsync(loader, deps) {
const [state, setState] = useState({ status: 'loading', data: null });
useEffect(() => {
let active = true;
setState({ status: 'loading', data: null });
loader().then((data) => {
if (active) setState({ status: data ? 'ready' : 'empty', data });
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}