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
+32 -6
View File
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' 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' import TopBar from './TopBar'
const RED_LETTER_CODE = /^jesusredletter$/i
function highlight(text: string, query: string): ReactNode { function highlight(text: string, query: string): ReactNode {
const ql = query.trim().toLowerCase() const ql = query.trim().toLowerCase()
if (!ql) return text if (!ql) return text
@@ -28,7 +31,7 @@ export default function SearchPage() {
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
useEffect(() => { useEffect(() => {
loadBible().then(() => setReady(true)) Promise.all([loadBible(), loadRedLetter()]).then(() => setReady(true))
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -36,7 +39,22 @@ export default function SearchPage() {
return () => window.clearTimeout(id) return () => window.clearTimeout(id)
}, [query]) }, [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(() => { useEffect(() => {
setResults(outcome.results) setResults(outcome.results)
@@ -70,10 +88,16 @@ export default function SearchPage() {
<p className="search-meta">Enter a word or phrase to search all 31,102 verses.</p> <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"> <p className="search-meta" aria-live="polite">
{total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'} {total > 0 ? `${total} verse${total === 1 ? '' : 's'} found` : 'No verses found.'}
{total > results.length && ` · showing the first ${results.length}`}
</p> </p>
)} )}
@@ -85,7 +109,9 @@ export default function SearchPage() {
to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`} to={`/read/${encodeURIComponent(r.book)}/${r.chapter}#v${r.verse}`}
> >
<span className="search-result-ref">{r.reference}</span> <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> </Link>
))} ))}
</div> </div>
+7 -7
View File
@@ -65,7 +65,7 @@ export function loadBible(): Promise<void> {
index.push({ index.push({
book: b.book, book: b.book,
chapter: cn, chapter: cn,
verse: v.verse, verse: Number(v.verse),
text: v.text, text: v.text,
lower: v.text.toLowerCase(), lower: v.text.toLowerCase(),
refLower: `${displayName(b.book)} ${cn}:${v.verse}`.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] 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 { function displayName(bookName: string): string {
return bookName === 'Psalms' ? 'Psalm' : bookName return bookName === 'Psalms' ? 'Psalm' : bookName
} }
@@ -101,18 +105,14 @@ export function referenceFor(book: KJVBookData, chapter: number, verse: number):
return `${displayName(book.book)} ${chapter}:${verse}` 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() const q = query.trim().toLowerCase()
if (!q) return { results: [], total: 0 } if (!q) return { results: [], total: 0 }
const results: SearchResult[] = [] const results: SearchResult[] = []
let total = 0
for (const e of index) { for (const e of index) {
if (e.lower.includes(q) || e.refLower.includes(q)) { 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: results.length }
return { results, total }
} }
+26
View File
@@ -1,8 +1,19 @@
export type RedLetterEntry = string | null export type RedLetterEntry = string | null
export interface RedLetterVerse {
book: string
chapter: number
verse: number
reference: string
quote: string | null
}
let loaded = false let loaded = false
let promise: Promise<void> | null = null let promise: Promise<void> | null = null
let map: Map<string, string | null> = new Map() let map: Map<string, string | null> = new Map()
let entries: RedLetterVerse[] = []
const refRe = /^([A-Za-z]+) (\d+):(\d+)$/
export function loadRedLetter(): Promise<void> { export function loadRedLetter(): Promise<void> {
if (loaded) return Promise.resolve() if (loaded) return Promise.resolve()
@@ -14,8 +25,19 @@ export function loadRedLetter(): Promise<void> {
}) })
.then((data) => { .then((data) => {
map = new Map() map = new Map()
entries = []
for (const [ref, value] of Object.entries(data.verses)) { for (const [ref, value] of Object.entries(data.verses)) {
map.set(ref.toLowerCase(), value === 'full' ? null : value) 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 loaded = true
}) })
@@ -28,6 +50,10 @@ export function redLetterFor(book: string, chapter: number, verse: number): RedL
return map.get(`${name} ${chapter}:${verse}`.toLowerCase()) return map.get(`${name} ${chapter}:${verse}`.toLowerCase())
} }
export function allRedLetter(): RedLetterVerse[] {
return entries
}
function normalize(s: string): string { function normalize(s: string): string {
let out = '' let out = ''
for (const c of s) { for (const c of s) {
+27
View File
@@ -719,6 +719,33 @@ a {
margin: 1.1rem 0 2rem; 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 { .search-results {
max-width: 46rem; max-width: 46rem;
margin: 0 auto; margin: 0 auto;
+3 -3
View File
@@ -51,14 +51,14 @@ 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) {
const entry = redLetterFor(book.book, chapterNumber, v.verse) const entry = redLetterFor(book.book, chapterNumber, Number(v.verse))
if (entry === undefined) continue if (entry === undefined) continue
flatRed.push({ flatRed.push({
text: entry === null ? v.text : entry, text: entry === null ? v.text : entry,
reference: `${name} ${chapterNumber}:${v.verse}`, reference: `${name} ${chapterNumber}:${Number(v.verse)}`,
book: book.book, book: book.book,
chapter: chapterNumber, chapter: chapterNumber,
verse: v.verse, verse: Number(v.verse),
}) })
} }
} }