RED LETTER CODE

This commit is contained in:
h202-wq
2026-08-19 10:38:33 -04:00
parent 2e1b64c2fd
commit 8919901a49
5 changed files with 97 additions and 18 deletions
+33 -7
View File
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { loadBible, searchBible, type SearchResult } from './bible'
import { getVerseText, loadBible, searchBible, type SearchResult } from './bible'
import { allRedLetter, loadRedLetter } from './redletter'
import TopBar from './TopBar'
const RED_LETTER_CODE = /^jesusredletter$/i
function highlight(text: string, query: string): ReactNode {
const ql = query.trim().toLowerCase()
if (!ql) return text
@@ -28,7 +31,7 @@ export default function SearchPage() {
const [total, setTotal] = useState(0)
useEffect(() => {
loadBible().then(() => setReady(true))
Promise.all([loadBible(), loadRedLetter()]).then(() => setReady(true))
}, [])
useEffect(() => {
@@ -36,7 +39,22 @@ export default function SearchPage() {
return () => window.clearTimeout(id)
}, [query])
const outcome = useMemo(() => searchBible(debounced), [debounced])
const secret = RED_LETTER_CODE.test(debounced.trim())
const outcome = useMemo(() => {
if (secret) {
const results: SearchResult[] = allRedLetter().map((e) => ({
text: e.quote ?? getVerseText(e.book, e.chapter, e.verse) ?? '',
reference: e.reference,
book: e.book,
chapter: e.chapter,
verse: e.verse,
}))
return { results, total: results.length, redLetter: true }
}
const res = searchBible(debounced)
return { results: res.results, total: res.total, redLetter: false }
}, [secret, debounced, ready])
useEffect(() => {
setResults(outcome.results)
@@ -66,14 +84,20 @@ export default function SearchPage() {
{ready && searching && <p className="search-meta">Searching</p>}
{ready && !searching && query.trim() === '' && (
{ready && !searching && query.trim() === '' && (
<p className="search-meta">Enter a word or phrase to search all 31,102 verses.</p>
)}
{ready && !searching && query.trim() !== '' && (
{ready && !searching && secret && (
<div className="redletter-banner" aria-live="polite">
<h2>The Words of Christ</h2>
<p>{total} verses the recorded words of Jesus</p>
</div>
)}
{ready && !searching && query.trim() !== '' && !secret && (
<p className="search-meta" aria-live="polite">
{total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'}
{total > results.length && ` · showing the first ${results.length}`}
</p>
)}
@@ -85,7 +109,9 @@ export default function SearchPage() {
to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`}
>
<span className="search-result-ref">{r.reference}</span>
<span className="search-result-text">{highlight(r.text, query)}</span>
<span className="search-result-text">
{outcome.redLetter ? <span className="jesus">{r.text}</span> : highlight(r.text, debounced)}
</span>
</Link>
))}
</div>
+8 -8
View File
@@ -65,7 +65,7 @@ export function loadBible(): Promise<void> {
index.push({
book: b.book,
chapter: cn,
verse: v.verse,
verse: Number(v.verse),
text: v.text,
lower: v.text.toLowerCase(),
refLower: `${displayName(b.book)} ${cn}:${v.verse}`.toLowerCase(),
@@ -93,6 +93,10 @@ export function getChapter(book: KJVBookData, chapter: number): KJVChapterData |
return book.chapters[chapter - 1]
}
export function getVerseText(book: string, chapter: number, verse: number): string | undefined {
return index.find((e) => e.book === book && Number(e.chapter) === chapter && Number(e.verse) === verse)?.text
}
function displayName(bookName: string): string {
return bookName === 'Psalms' ? 'Psalm' : bookName
}
@@ -101,18 +105,14 @@ export function referenceFor(book: KJVBookData, chapter: number, verse: number):
return `${displayName(book.book)} ${chapter}:${verse}`
}
export function searchBible(query: string, limit = 60): SearchOutcome {
export function searchBible(query: string): SearchOutcome {
const q = query.trim().toLowerCase()
if (!q) return { results: [], total: 0 }
const results: SearchResult[] = []
let total = 0
for (const e of index) {
if (e.lower.includes(q) || e.refLower.includes(q)) {
total++
if (results.length < limit) {
results.push({ text: e.text, reference: `${displayName(e.book)} ${e.chapter}:${e.verse}`, book: e.book, chapter: e.chapter, verse: e.verse })
}
results.push({ text: e.text, reference: `${displayName(e.book)} ${e.chapter}:${e.verse}`, book: e.book, chapter: e.chapter, verse: e.verse })
}
}
return { results, total }
return { results, total: results.length }
}
+26
View File
@@ -1,8 +1,19 @@
export type RedLetterEntry = string | null
export interface RedLetterVerse {
book: string
chapter: number
verse: number
reference: string
quote: string | null
}
let loaded = false
let promise: Promise<void> | null = null
let map: Map<string, string | null> = new Map()
let entries: RedLetterVerse[] = []
const refRe = /^([A-Za-z]+) (\d+):(\d+)$/
export function loadRedLetter(): Promise<void> {
if (loaded) return Promise.resolve()
@@ -14,8 +25,19 @@ export function loadRedLetter(): Promise<void> {
})
.then((data) => {
map = new Map()
entries = []
for (const [ref, value] of Object.entries(data.verses)) {
map.set(ref.toLowerCase(), value === 'full' ? null : value)
const m = refRe.exec(ref)
if (m) {
entries.push({
book: m[1],
chapter: Number(m[2]),
verse: Number(m[3]),
reference: ref,
quote: value === 'full' ? null : value,
})
}
}
loaded = true
})
@@ -28,6 +50,10 @@ export function redLetterFor(book: string, chapter: number, verse: number): RedL
return map.get(`${name} ${chapter}:${verse}`.toLowerCase())
}
export function allRedLetter(): RedLetterVerse[] {
return entries
}
function normalize(s: string): string {
let out = ''
for (const c of s) {
+27
View File
@@ -719,6 +719,33 @@ a {
margin: 1.1rem 0 2rem;
}
.redletter-banner {
text-align: center;
margin: 1.1rem 0 2rem;
padding: 1.4rem 1.5rem;
border: 1px solid rgba(208, 95, 90, 0.45);
border-radius: 8px;
background: rgba(208, 95, 90, 0.08);
}
.redletter-banner h2 {
font-family: var(--display);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3em;
text-indent: 0.3em;
font-size: 1.15rem;
color: var(--red);
margin: 0 0 0.35rem;
}
.redletter-banner p {
font-style: italic;
color: var(--ink-dim);
font-size: 0.88rem;
margin: 0;
}
.search-results {
max-width: 46rem;
margin: 0 auto;
+3 -3
View File
@@ -51,14 +51,14 @@ export async function loadKJV(): Promise<DailyVerse[]> {
for (const chapter of book.chapters) {
const chapterNumber = Number(chapter.chapter)
for (const v of chapter.verses) {
const entry = redLetterFor(book.book, chapterNumber, v.verse)
const entry = redLetterFor(book.book, chapterNumber, Number(v.verse))
if (entry === undefined) continue
flatRed.push({
text: entry === null ? v.text : entry,
reference: `${name} ${chapterNumber}:${v.verse}`,
reference: `${name} ${chapterNumber}:${Number(v.verse)}`,
book: book.book,
chapter: chapterNumber,
verse: v.verse,
verse: Number(v.verse),
})
}
}