Add word-by-word Greek/Hebrew interlinear under Scripture panel

Adds a collapsible Interlinear section beneath the Scripture text
(stacked and split layouts) showing each original-language word with
its BSB gloss, transliteration, parsing, and Strong's number on hover.
Data is generated offline from the Berean Standard Bible translation
tables via scripts/build-interlinear.mjs into per-book JSON files.
This commit is contained in:
nmemmert
2026-06-11 14:29:24 -04:00
parent 6c4658e244
commit bb92095ba4
70 changed files with 255 additions and 1 deletions
+8
View File
@@ -24,6 +24,7 @@
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1",
"jsdom": "^29.1.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.4",
@@ -2852,6 +2853,13 @@
"node": ">=4"
}
},
"node_modules/csv-parse": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz",
"integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+1
View File
@@ -31,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1",
"jsdom": "^29.1.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.4",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+96
View File
@@ -0,0 +1,96 @@
// One-time/offline data prep: convert the BSB Translation Tables (bereanbible.com/bsb_tables.tsv)
// into per-book JSON files of word-by-word interlinear data for /public/interlinear/.
//
// Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
const BOOK_ABBREV = {
Genesis: 'GEN', Exodus: 'EXO', Leviticus: 'LEV', Numbers: 'NUM', Deuteronomy: 'DEU',
Joshua: 'JOS', Judges: 'JDG', Ruth: 'RUT', '1 Samuel': '1SA', '2 Samuel': '2SA',
'1 Kings': '1KI', '2 Kings': '2KI', '1 Chronicles': '1CH', '2 Chronicles': '2CH',
Ezra: 'EZR', Nehemiah: 'NEH', Esther: 'EST', Job: 'JOB', Psalm: 'PSA', Proverbs: 'PRO',
Ecclesiastes: 'ECC', 'Song of Solomon': 'SNG', Isaiah: 'ISA', Jeremiah: 'JER',
Lamentations: 'LAM', Ezekiel: 'EZK', Daniel: 'DAN', Hosea: 'HOS', Joel: 'JOL',
Amos: 'AMO', Obadiah: 'OBA', Jonah: 'JON', Micah: 'MIC', Nahum: 'NAM', Habakkuk: 'HAB',
Zephaniah: 'ZEP', Haggai: 'HAG', Zechariah: 'ZEC', Malachi: 'MAL',
Matthew: 'MAT', Mark: 'MRK', Luke: 'LUK', John: 'JHN', Acts: 'ACT', Romans: 'ROM',
'1 Corinthians': '1CO', '2 Corinthians': '2CO', Galatians: 'GAL', Ephesians: 'EPH',
Philippians: 'PHP', Colossians: 'COL', '1 Thessalonians': '1TH', '2 Thessalonians': '2TH',
'1 Timothy': '1TI', '2 Timothy': '2TI', Titus: 'TIT', Philemon: 'PHM', Hebrews: 'HEB',
James: 'JAS', '1 Peter': '1PE', '2 Peter': '2PE', '1 John': '1JN', '2 John': '2JN',
'3 John': '3JN', Jude: 'JUD', Revelation: 'REV',
};
const inputPath = process.argv[2];
if (!inputPath) {
console.error('Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv');
process.exit(1);
}
const outDir = path.resolve('public/interlinear');
fs.mkdirSync(outDir, { recursive: true });
const raw = fs.readFileSync(inputPath, 'utf-8');
const records = parse(raw, { delimiter: '\t', columns: false, relax_column_count: true });
const header = records[0];
const idx = Object.fromEntries(header.map((h, i) => [h.trim(), i]));
// books[ABBREV][chapter][verse] = [{ o, t, p, s, g }]
const books = {};
let curVerseId = '';
for (let i = 1; i < records.length; i++) {
const row = records[i];
const verseId = row[idx.VerseId];
if (verseId) curVerseId = verseId;
if (!curVerseId) continue;
const lang = row[idx.Language];
const original = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['WLC / Nestle Base TR RP WH NE NA SBL']]
: row[idx['WLC / Nestle Base TR RP WH NE NA SBL']];
if (!original || !original.trim()) continue;
const strongs = row[idx['Str Heb']] || row[idx['Str Grk']] || '';
const gloss = (row[idx['BSB version']] || '').replace(/\s+/g, ' ').trim();
const translit = (row[idx.Translit] || '').trim();
const parsing = (row[idx.Parsing] || '').trim();
const sortKey = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['Heb Sort']]
: row[idx['Greek Sort']];
const m = curVerseId.match(/^(.*) (\d+):(\d+)$/);
if (!m) continue;
const [, bookName, chapter, verse] = m;
const abbrev = BOOK_ABBREV[bookName];
if (!abbrev) continue;
books[abbrev] ??= {};
books[abbrev][chapter] ??= {};
books[abbrev][chapter][verse] ??= [];
books[abbrev][chapter][verse].push({
sort: Number(sortKey) || 0,
o: original.trim(),
t: translit,
p: parsing,
s: strongs ? `${(lang === 'Hebrew' || lang === 'Aramaic') ? 'H' : 'G'}${strongs}` : '',
g: gloss,
});
}
let fileCount = 0;
for (const [abbrev, chapters] of Object.entries(books)) {
for (const chapter of Object.values(chapters)) {
for (const verse of Object.values(chapter)) {
verse.sort((a, b) => a.sort - b.sort);
for (const w of verse) delete w.sort;
}
}
fs.writeFileSync(path.join(outDir, `${abbrev}.json`), JSON.stringify(chapters));
fileCount++;
}
console.log(`Wrote ${fileCount} book files to ${outDir}`);
+84 -1
View File
@@ -913,6 +913,19 @@ const App = () => {
const [commentaryLoading, setCommentaryLoading] = useState(false);
const [commentaryError, setCommentaryError] = useState('');
const _commentaryCacheRef = useRef({});
const [interlinearData, setInterlinearData] = useState(null);
const [interlinearLoading, setInterlinearLoading] = useState(false);
const [interlinearError, setInterlinearError] = useState('');
const _interlinearCacheRef = useRef({});
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
if (_interlinearCacheRef.current[bookAbbrev]) return _interlinearCacheRef.current[bookAbbrev];
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
if (!res.ok) throw new Error('No interlinear data for this book.');
const data = await res.json();
_interlinearCacheRef.current[bookAbbrev] = data;
return data;
};
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
@@ -988,6 +1001,21 @@ const App = () => {
.finally(() => setCommentaryLoading(false));
}, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]);
useEffect(() => {
if (collapsedSections.interlinear) return;
if (selectedChunkChapterIndex < 0) return;
const chapter = project.chapters[selectedChunkChapterIndex];
setInterlinearLoading(true);
setInterlinearError('');
loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter)
.then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null))
.catch((err) => {
setInterlinearData(null);
setInterlinearError(err.message);
})
.finally(() => setInterlinearLoading(false));
}, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]);
useEffect(() => {
setCrossRefSuggestions([]);
setTagInput('');
@@ -3277,6 +3305,46 @@ const restoreRemoteProject = async (id) => {
</p>
))}
</div>
{/* Interlinear */}
<div className="mt-4 border-t border-slate-200 pt-4">
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, interlinear: !c.interlinear }))}
className="mb-2 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Interlinear</h3>
<span className="text-slate-400">{collapsedSections.interlinear ? '▸' : '▾'}</span>
</button>
{!collapsedSections.interlinear && (
<div
className="space-y-2 font-serif text-slate-800"
dir={selectedChunkChapter && !NT_BOOK_NUMBER[selectedChunkChapter.bookAbbrev] ? 'rtl' : 'ltr'}
>
{interlinearLoading && <p className="text-sm font-sans text-slate-500">Loading interlinear text...</p>}
{interlinearError && <p className="text-sm font-sans text-rose-500">{interlinearError}</p>}
{!interlinearLoading && !interlinearError && interlinearData
&& Array.from(
{ length: selectedChunk.endVerse - selectedChunk.startVerse + 1 },
(_, i) => selectedChunk.startVerse + i,
)
.filter((num) => interlinearData[String(num)])
.map((num) => (
<div key={num} className="leading-relaxed">
<span className="font-sans font-semibold text-slate-700">{selectedChunkChapter.chapter}:{num}.</span>{' '}
<span className="mt-1 inline-flex flex-wrap gap-x-3 gap-y-2 align-top">
{interlinearData[String(num)].map((w, i) => (
<span key={i} className="inline-flex flex-col items-center text-center" title={[w.t, w.s, w.p].filter(Boolean).join(' · ')}>
<span className="text-base">{w.o}</span>
<span className="font-sans text-[11px] leading-tight text-slate-500">{w.g || '—'}</span>
</span>
))}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right column (study panels) */}
@@ -3755,7 +3823,22 @@ const restoreRemoteProject = async (id) => {
<div key={v.number}>
<p className="text-sm font-semibold text-slate-700">Verse {v.number}</p>
{(v.content ?? []).map((p, i) => (
<p key={i} className="mt-1 text-sm text-slate-600">{p}</p>
<p
key={i}
className="mt-1 text-sm text-slate-600 cursor-context-menu"
title="Right-click to add to Background / General Notes"
onContextMenu={(e) => {
e.preventDefault();
const sourceName = COMMENTARY_OPTIONS.find((c) => c.id === commentarySource)?.name ?? commentarySource;
const ref = formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '');
const citation = `${p} (${sourceName}, ${ref}:${v.number})`;
const existing = selectedChunk.generalNotes ?? '';
const next = existing ? `${existing}\n\n${citation}` : citation;
updateChunk(selectedChunk.id, { generalNotes: next });
}}
>
{p}
</p>
))}
</div>
))}