red letter

This commit is contained in:
h202-wq
2026-08-19 10:01:28 -04:00
parent 2fc4976753
commit 2e1b64c2fd
7 changed files with 2135 additions and 9 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@ export default function Home() {
<p className="countdown"> <p className="countdown">
A new verse is appointed in <strong>{countdown}</strong> A new verse is appointed in <strong>{countdown}</strong>
</p> </p>
<p className="foot-note">King James Version · Public Domain · The New Testament</p> <p className="foot-note">King James Version · Public Domain · Red Letter Edition</p>
</footer> </footer>
</div> </div>
) )
+4 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, Navigate, useLocation, useParams } from 'react-router-dom' import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
import { findBook, getBooks, loadBible, type KJVBookData } from './bible' import { findBook, getBooks, loadBible, type KJVBookData } from './bible'
import { loadRedLetter, redLetterFor } from './redletter'
import RedText from './redtext'
import TopBar from './TopBar' import TopBar from './TopBar'
const FONT_KEY = 'wbf-font-size' const FONT_KEY = 'wbf-font-size'
@@ -30,7 +32,7 @@ export default function Reader() {
const chapterData = valid && bookData ? bookData.chapters[chapterNum - 1] : undefined const chapterData = valid && bookData ? bookData.chapters[chapterNum - 1] : undefined
useEffect(() => { useEffect(() => {
loadBible().then(() => setLoaded(true)) Promise.all([loadBible(), loadRedLetter()]).then(() => setLoaded(true))
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -160,7 +162,7 @@ export default function Reader() {
{chapterData?.verses.map((v) => ( {chapterData?.verses.map((v) => (
<p className="verse-line" id={`v${v.verse}`} key={v.verse}> <p className="verse-line" id={`v${v.verse}`} key={v.verse}>
<sup>{v.verse}</sup> <sup>{v.verse}</sup>
{v.text} <RedText text={v.text} entry={redLetterFor(bookData.book, chapterNum, v.verse)} />
</p> </p>
))} ))}
</div> </div>
+78
View File
@@ -0,0 +1,78 @@
export type RedLetterEntry = string | null
let loaded = false
let promise: Promise<void> | null = null
let map: Map<string, string | null> = new Map()
export function loadRedLetter(): Promise<void> {
if (loaded) return Promise.resolve()
if (!promise) {
promise = fetch('/red_letter.json')
.then((r) => {
if (!r.ok) throw new Error(`Failed to load red letter data (${r.status})`)
return r.json() as Promise<{ verses: Record<string, string> }>
})
.then((data) => {
map = new Map()
for (const [ref, value] of Object.entries(data.verses)) {
map.set(ref.toLowerCase(), value === 'full' ? null : value)
}
loaded = true
})
}
return promise
}
export function redLetterFor(book: string, chapter: number, verse: number): RedLetterEntry | undefined {
const name = book === 'Psalms' ? 'Psalm' : book
return map.get(`${name} ${chapter}:${verse}`.toLowerCase())
}
function normalize(s: string): string {
let out = ''
for (const c of s) {
if (/\s/.test(c)) out += ' '
else if (/[a-z0-9]/i.test(c)) out += c.toLowerCase()
}
return out
}
function spansOf(text: string): Array<{ ch: string; i: number }> {
const arr: Array<{ ch: string; i: number }> = []
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (/\s/.test(c)) arr.push({ ch: ' ', i })
else if (/[a-z0-9]/i.test(c)) arr.push({ ch: c.toLowerCase(), i })
}
return arr
}
export function redLetterSpan(text: string, quote: string): [number, number] | null {
const raw = text.indexOf(quote)
if (raw >= 0) return [raw, raw + quote.length]
const arr = spansOf(text)
const normText = arr.map((o) => o.ch).join('')
const normQuote = normalize(quote)
const qlen = normQuote.length
if (!qlen || qlen > normText.length) return null
const idx = normText.indexOf(normQuote)
if (idx >= 0) return [arr[idx].i, arr[idx + qlen - 1].i + 1]
let best = -1
let bestScore = Infinity
for (let i = 0; i + qlen <= normText.length; i++) {
let diff = 0
for (let j = 0; j < qlen; j++) {
if (normText[i + j] !== normQuote[j]) diff++
}
if (diff < bestScore) {
bestScore = diff
best = i
if (diff === 0) break
}
}
if (bestScore <= 2) return [arr[best].i, arr[best + qlen - 1].i + 1]
return null
}
+19
View File
@@ -0,0 +1,19 @@
import { redLetterSpan, type RedLetterEntry } from './redletter'
export default function RedText({ text, entry }: { text: string; entry: RedLetterEntry | undefined }) {
if (entry === null) return <span className="jesus">{text}</span>
if (typeof entry === 'string') {
const span = redLetterSpan(text, entry)
if (span) {
const [s, e] = span
return (
<>
{text.slice(0, s)}
<span className="jesus">{text.slice(s, e)}</span>
{text.slice(e)}
</>
)
}
}
return <>{text}</>
}
+5
View File
@@ -5,6 +5,7 @@
--ink-faint: rgba(160, 138, 104, 0.55); --ink-faint: rgba(160, 138, 104, 0.55);
--accent: #f0a94f; --accent: #f0a94f;
--accent-soft: #ffd9a0; --accent-soft: #ffd9a0;
--red: #d05f5a;
--line: rgba(240, 169, 79, 0.28); --line: rgba(240, 169, 79, 0.28);
--serif: 'EB Garamond', Georgia, 'Times New Roman', serif; --serif: 'EB Garamond', Georgia, 'Times New Roman', serif;
--display: 'Cinzel', 'EB Garamond', Georgia, serif; --display: 'Cinzel', 'EB Garamond', Georgia, serif;
@@ -612,6 +613,10 @@ a {
user-select: none; user-select: none;
} }
.jesus {
color: var(--red);
}
.verse-line.flash { .verse-line.flash {
animation: verse-flash 2s ease-out; animation: verse-flash 2s ease-out;
} }
+15 -6
View File
@@ -1,4 +1,5 @@
import { getBooks, loadBible, type Verse } from './bible' import { getBooks, loadBible, type Verse } from './bible'
import { loadRedLetter, redLetterFor } from './redletter'
const ROLLOVER_LOCAL_HOUR = 2 const ROLLOVER_LOCAL_HOUR = 2
@@ -8,7 +9,7 @@ export interface DailyVerse extends Verse {
verse: number verse: number
} }
let flatNT: DailyVerse[] | null = null let flatRed: DailyVerse[] | null = null
export function daySeed(now: Date = new Date()): number { export function daySeed(now: Date = new Date()): number {
const shifted = new Date(now.getTime() - ROLLOVER_LOCAL_HOUR * 3600_000) const shifted = new Date(now.getTime() - ROLLOVER_LOCAL_HOUR * 3600_000)
@@ -39,9 +40,9 @@ export function pickForDay(verses: DailyVerse[], seed: number): DailyVerse {
} }
export async function loadKJV(): Promise<DailyVerse[]> { export async function loadKJV(): Promise<DailyVerse[]> {
if (flatNT) return flatNT if (flatRed) return flatRed
await loadBible() await Promise.all([loadBible(), loadRedLetter()])
flatNT = [] flatRed = []
let inNewTestament = false let inNewTestament = false
for (const book of getBooks()) { for (const book of getBooks()) {
if (book.book === 'Matthew') inNewTestament = true if (book.book === 'Matthew') inNewTestament = true
@@ -50,9 +51,17 @@ export async function loadKJV(): Promise<DailyVerse[]> {
for (const chapter of book.chapters) { for (const chapter of book.chapters) {
const chapterNumber = Number(chapter.chapter) const chapterNumber = Number(chapter.chapter)
for (const v of chapter.verses) { for (const v of chapter.verses) {
flatNT.push({ text: v.text, reference: `${name} ${chapterNumber}:${v.verse}`, book: book.book, chapter: chapterNumber, verse: v.verse }) const entry = redLetterFor(book.book, chapterNumber, v.verse)
if (entry === undefined) continue
flatRed.push({
text: entry === null ? v.text : entry,
reference: `${name} ${chapterNumber}:${v.verse}`,
book: book.book,
chapter: chapterNumber,
verse: v.verse,
})
} }
} }
} }
return flatNT return flatRed
} }