Skip to content

Instantly share code, notes, and snippets.

@izakfilmalter
Created December 2, 2025 19:58
Show Gist options
  • Select an option

  • Save izakfilmalter/6e699073805209c69f207f413368f1ba to your computer and use it in GitHub Desktop.

Select an option

Save izakfilmalter/6e699073805209c69f207f413368f1ba to your computer and use it in GitHub Desktop.
verses helpers
/**
* Bible verse utilities for parsing and normalizing verse ranges
*/
import { Array, Option, pipe, Record, String } from 'effect'
// ============================================================================
// Core Types
// ============================================================================
export type VerseReference = {
book: string
chapter: number
verse: number
}
export type VerseRange = {
start: VerseReference
end: VerseReference
}
export type Testament = 'old' | 'new'
/**
* Type for the compressed verse data stored in verses.json.
* Each entry represents a chapter with its verse range.
*/
export type ChapterData = {
book: BibleBookName
bookOrder: number
testament: Testament
chapter: number
startVerse: number
endVerse: number
}
/**
* Type for a fully expanded verse record as stored in the database.
*/
export type VerseRecord = {
id: string
reference: string
book: string
chapter: number
verse: number
bookOrder: number
testament: Testament
}
// ============================================================================
// Bible Book Data
// ============================================================================
/**
* Canonical book order and metadata for all 66 books of the Bible.
* Order follows the standard Protestant canon.
*/
export const BIBLE_BOOKS = [
// Old Testament (1-39)
{ order: 1, name: 'Genesis', testament: 'old' },
{ order: 2, name: 'Exodus', testament: 'old' },
{ order: 3, name: 'Leviticus', testament: 'old' },
{ order: 4, name: 'Numbers', testament: 'old' },
{ order: 5, name: 'Deuteronomy', testament: 'old' },
{ order: 6, name: 'Joshua', testament: 'old' },
{ order: 7, name: 'Judges', testament: 'old' },
{ order: 8, name: 'Ruth', testament: 'old' },
{ order: 9, name: '1 Samuel', testament: 'old' },
{ order: 10, name: '2 Samuel', testament: 'old' },
{ order: 11, name: '1 Kings', testament: 'old' },
{ order: 12, name: '2 Kings', testament: 'old' },
{ order: 13, name: '1 Chronicles', testament: 'old' },
{ order: 14, name: '2 Chronicles', testament: 'old' },
{ order: 15, name: 'Ezra', testament: 'old' },
{ order: 16, name: 'Nehemiah', testament: 'old' },
{ order: 17, name: 'Esther', testament: 'old' },
{ order: 18, name: 'Job', testament: 'old' },
{ order: 19, name: 'Psalms', testament: 'old' },
{ order: 20, name: 'Proverbs', testament: 'old' },
{ order: 21, name: 'Ecclesiastes', testament: 'old' },
{ order: 22, name: 'Song of Solomon', testament: 'old' },
{ order: 23, name: 'Isaiah', testament: 'old' },
{ order: 24, name: 'Jeremiah', testament: 'old' },
{ order: 25, name: 'Lamentations', testament: 'old' },
{ order: 26, name: 'Ezekiel', testament: 'old' },
{ order: 27, name: 'Daniel', testament: 'old' },
{ order: 28, name: 'Hosea', testament: 'old' },
{ order: 29, name: 'Joel', testament: 'old' },
{ order: 30, name: 'Amos', testament: 'old' },
{ order: 31, name: 'Obadiah', testament: 'old' },
{ order: 32, name: 'Jonah', testament: 'old' },
{ order: 33, name: 'Micah', testament: 'old' },
{ order: 34, name: 'Nahum', testament: 'old' },
{ order: 35, name: 'Habakkuk', testament: 'old' },
{ order: 36, name: 'Zephaniah', testament: 'old' },
{ order: 37, name: 'Haggai', testament: 'old' },
{ order: 38, name: 'Zechariah', testament: 'old' },
{ order: 39, name: 'Malachi', testament: 'old' },
// New Testament (40-66)
{ order: 40, name: 'Matthew', testament: 'new' },
{ order: 41, name: 'Mark', testament: 'new' },
{ order: 42, name: 'Luke', testament: 'new' },
{ order: 43, name: 'John', testament: 'new' },
{ order: 44, name: 'Acts', testament: 'new' },
{ order: 45, name: 'Romans', testament: 'new' },
{ order: 46, name: '1 Corinthians', testament: 'new' },
{ order: 47, name: '2 Corinthians', testament: 'new' },
{ order: 48, name: 'Galatians', testament: 'new' },
{ order: 49, name: 'Ephesians', testament: 'new' },
{ order: 50, name: 'Philippians', testament: 'new' },
{ order: 51, name: 'Colossians', testament: 'new' },
{ order: 52, name: '1 Thessalonians', testament: 'new' },
{ order: 53, name: '2 Thessalonians', testament: 'new' },
{ order: 54, name: '1 Timothy', testament: 'new' },
{ order: 55, name: '2 Timothy', testament: 'new' },
{ order: 56, name: 'Titus', testament: 'new' },
{ order: 57, name: 'Philemon', testament: 'new' },
{ order: 58, name: 'Hebrews', testament: 'new' },
{ order: 59, name: 'James', testament: 'new' },
{ order: 60, name: '1 Peter', testament: 'new' },
{ order: 61, name: '2 Peter', testament: 'new' },
{ order: 62, name: '1 John', testament: 'new' },
{ order: 63, name: '2 John', testament: 'new' },
{ order: 64, name: '3 John', testament: 'new' },
{ order: 65, name: 'Jude', testament: 'new' },
{ order: 66, name: 'Revelation', testament: 'new' },
] as const satisfies ReadonlyArray<{
order: number
name: string
testament: Testament
}>
export type BibleBook = (typeof BIBLE_BOOKS)[number]
export type BibleBookName = BibleBook['name']
/**
* Mapping of various book name formats to their canonical names.
* Includes abbreviations, Roman numeral variants, and alternate names.
*/
const BOOK_NAME_ALIASES: Record<string, BibleBookName> = {
// Genesis
gen: 'Genesis',
ge: 'Genesis',
gn: 'Genesis',
// Exodus
exod: 'Exodus',
exo: 'Exodus',
ex: 'Exodus',
// Leviticus
lev: 'Leviticus',
le: 'Leviticus',
lv: 'Leviticus',
// Numbers
num: 'Numbers',
nu: 'Numbers',
nm: 'Numbers',
nb: 'Numbers',
// Deuteronomy
deut: 'Deuteronomy',
de: 'Deuteronomy',
dt: 'Deuteronomy',
// Joshua
josh: 'Joshua',
jos: 'Joshua',
jsh: 'Joshua',
// Judges
judg: 'Judges',
jdg: 'Judges',
jg: 'Judges',
jdgs: 'Judges',
// Ruth
rut: 'Ruth',
ru: 'Ruth',
// 1 Samuel
'1sam': '1 Samuel',
'1sa': '1 Samuel',
'1sm': '1 Samuel',
'1 sam': '1 Samuel',
'1 sa': '1 Samuel',
'i samuel': '1 Samuel',
'i sam': '1 Samuel',
'1st samuel': '1 Samuel',
'first samuel': '1 Samuel',
// 2 Samuel
'2sam': '2 Samuel',
'2sa': '2 Samuel',
'2sm': '2 Samuel',
'2 sam': '2 Samuel',
'2 sa': '2 Samuel',
'ii samuel': '2 Samuel',
'ii sam': '2 Samuel',
'2nd samuel': '2 Samuel',
'second samuel': '2 Samuel',
// 1 Kings
'1kgs': '1 Kings',
'1ki': '1 Kings',
'1k': '1 Kings',
'1 kgs': '1 Kings',
'1 ki': '1 Kings',
'i kings': '1 Kings',
'i kgs': '1 Kings',
'1st kings': '1 Kings',
'first kings': '1 Kings',
// 2 Kings
'2kgs': '2 Kings',
'2ki': '2 Kings',
'2k': '2 Kings',
'2 kgs': '2 Kings',
'2 ki': '2 Kings',
'ii kings': '2 Kings',
'ii kgs': '2 Kings',
'2nd kings': '2 Kings',
'second kings': '2 Kings',
// 1 Chronicles
'1chr': '1 Chronicles',
'1ch': '1 Chronicles',
'1 chr': '1 Chronicles',
'1 ch': '1 Chronicles',
'i chronicles': '1 Chronicles',
'i chr': '1 Chronicles',
'1st chronicles': '1 Chronicles',
'first chronicles': '1 Chronicles',
// 2 Chronicles
'2chr': '2 Chronicles',
'2ch': '2 Chronicles',
'2 chr': '2 Chronicles',
'2 ch': '2 Chronicles',
'ii chronicles': '2 Chronicles',
'ii chr': '2 Chronicles',
'2nd chronicles': '2 Chronicles',
'second chronicles': '2 Chronicles',
// Ezra
ezr: 'Ezra',
// Nehemiah
neh: 'Nehemiah',
ne: 'Nehemiah',
// Esther
esth: 'Esther',
est: 'Esther',
es: 'Esther',
// Job
jb: 'Job',
// Psalms
ps: 'Psalms',
psa: 'Psalms',
psm: 'Psalms',
pss: 'Psalms',
psalm: 'Psalms',
// Proverbs
prov: 'Proverbs',
pro: 'Proverbs',
prv: 'Proverbs',
pr: 'Proverbs',
// Ecclesiastes
eccl: 'Ecclesiastes',
ecc: 'Ecclesiastes',
ec: 'Ecclesiastes',
qoh: 'Ecclesiastes',
// Song of Solomon
song: 'Song of Solomon',
sos: 'Song of Solomon',
'song of songs': 'Song of Solomon',
'songs of solomon': 'Song of Solomon',
canticles: 'Song of Solomon',
cant: 'Song of Solomon',
// Isaiah
isa: 'Isaiah',
is: 'Isaiah',
// Jeremiah
jer: 'Jeremiah',
je: 'Jeremiah',
jr: 'Jeremiah',
// Lamentations
lam: 'Lamentations',
la: 'Lamentations',
// Ezekiel
ezek: 'Ezekiel',
eze: 'Ezekiel',
ezk: 'Ezekiel',
// Daniel
dan: 'Daniel',
da: 'Daniel',
dn: 'Daniel',
// Hosea
hos: 'Hosea',
ho: 'Hosea',
// Joel
joe: 'Joel',
jl: 'Joel',
// Amos
am: 'Amos',
// Obadiah
obad: 'Obadiah',
ob: 'Obadiah',
// Jonah
jon: 'Jonah',
jnh: 'Jonah',
// Micah
mic: 'Micah',
mc: 'Micah',
// Nahum
nah: 'Nahum',
na: 'Nahum',
// Habakkuk
hab: 'Habakkuk',
hb: 'Habakkuk',
// Zephaniah
zeph: 'Zephaniah',
zep: 'Zephaniah',
zp: 'Zephaniah',
// Haggai
hag: 'Haggai',
hg: 'Haggai',
// Zechariah
zech: 'Zechariah',
zec: 'Zechariah',
zc: 'Zechariah',
// Malachi
mal: 'Malachi',
ml: 'Malachi',
// Matthew
matt: 'Matthew',
mat: 'Matthew',
mt: 'Matthew',
// Mark
mrk: 'Mark',
mar: 'Mark',
mk: 'Mark',
mr: 'Mark',
// Luke
luk: 'Luke',
lk: 'Luke',
// John (Gospel)
joh: 'John',
jhn: 'John',
jn: 'John',
// Acts
act: 'Acts',
ac: 'Acts',
// Romans
rom: 'Romans',
ro: 'Romans',
rm: 'Romans',
// 1 Corinthians
'1cor': '1 Corinthians',
'1co': '1 Corinthians',
'1 cor': '1 Corinthians',
'1 co': '1 Corinthians',
'i corinthians': '1 Corinthians',
'i cor': '1 Corinthians',
'1st corinthians': '1 Corinthians',
'first corinthians': '1 Corinthians',
// 2 Corinthians
'2cor': '2 Corinthians',
'2co': '2 Corinthians',
'2 cor': '2 Corinthians',
'2 co': '2 Corinthians',
'ii corinthians': '2 Corinthians',
'ii cor': '2 Corinthians',
'2nd corinthians': '2 Corinthians',
'second corinthians': '2 Corinthians',
// Galatians
gal: 'Galatians',
ga: 'Galatians',
// Ephesians
eph: 'Ephesians',
ephes: 'Ephesians',
// Philippians
phil: 'Philippians',
php: 'Philippians',
pp: 'Philippians',
// Colossians
col: 'Colossians',
// 1 Thessalonians
'1thess': '1 Thessalonians',
'1thes': '1 Thessalonians',
'1th': '1 Thessalonians',
'1 thess': '1 Thessalonians',
'1 thes': '1 Thessalonians',
'1 th': '1 Thessalonians',
'i thessalonians': '1 Thessalonians',
'i thess': '1 Thessalonians',
'1st thessalonians': '1 Thessalonians',
'first thessalonians': '1 Thessalonians',
// 2 Thessalonians
'2thess': '2 Thessalonians',
'2thes': '2 Thessalonians',
'2th': '2 Thessalonians',
'2 thess': '2 Thessalonians',
'2 thes': '2 Thessalonians',
'2 th': '2 Thessalonians',
'ii thessalonians': '2 Thessalonians',
'ii thess': '2 Thessalonians',
'2nd thessalonians': '2 Thessalonians',
'second thessalonians': '2 Thessalonians',
// 1 Timothy
'1tim': '1 Timothy',
'1ti': '1 Timothy',
'1 tim': '1 Timothy',
'1 ti': '1 Timothy',
'i timothy': '1 Timothy',
'i tim': '1 Timothy',
'1st timothy': '1 Timothy',
'first timothy': '1 Timothy',
// 2 Timothy
'2tim': '2 Timothy',
'2ti': '2 Timothy',
'2 tim': '2 Timothy',
'2 ti': '2 Timothy',
'ii timothy': '2 Timothy',
'ii tim': '2 Timothy',
'2nd timothy': '2 Timothy',
'second timothy': '2 Timothy',
// Titus
tit: 'Titus',
ti: 'Titus',
// Philemon
phm: 'Philemon',
philem: 'Philemon',
pm: 'Philemon',
// Hebrews
heb: 'Hebrews',
// James
jas: 'James',
jm: 'James',
// 1 Peter
'1pet': '1 Peter',
'1pe': '1 Peter',
'1pt': '1 Peter',
'1p': '1 Peter',
'1 pet': '1 Peter',
'1 pe': '1 Peter',
'i peter': '1 Peter',
'i pet': '1 Peter',
'1st peter': '1 Peter',
'first peter': '1 Peter',
// 2 Peter
'2pet': '2 Peter',
'2pe': '2 Peter',
'2pt': '2 Peter',
'2p': '2 Peter',
'2 pet': '2 Peter',
'2 pe': '2 Peter',
'ii peter': '2 Peter',
'ii pet': '2 Peter',
'2nd peter': '2 Peter',
'second peter': '2 Peter',
// 1 John
'1john': '1 John',
'1joh': '1 John',
'1jhn': '1 John',
'1jn': '1 John',
'1j': '1 John',
'1 john': '1 John',
'1 joh': '1 John',
'1 jhn': '1 John',
'1 jn': '1 John',
'i john': '1 John',
'i joh': '1 John',
'1st john': '1 John',
'first john': '1 John',
// 2 John
'2john': '2 John',
'2joh': '2 John',
'2jhn': '2 John',
'2jn': '2 John',
'2j': '2 John',
'2 john': '2 John',
'2 joh': '2 John',
'2 jhn': '2 John',
'2 jn': '2 John',
'ii john': '2 John',
'ii joh': '2 John',
'2nd john': '2 John',
'second john': '2 John',
// 3 John
'3john': '3 John',
'3joh': '3 John',
'3jhn': '3 John',
'3jn': '3 John',
'3j': '3 John',
'3 john': '3 John',
'3 joh': '3 John',
'3 jhn': '3 John',
'3 jn': '3 John',
'iii john': '3 John',
'iii joh': '3 John',
'3rd john': '3 John',
'third john': '3 John',
// Jude
jud: 'Jude',
jde: 'Jude',
// Revelation
rev: 'Revelation',
re: 'Revelation',
'revelation of john': 'Revelation',
'the revelation': 'Revelation',
apocalypse: 'Revelation',
apoc: 'Revelation',
}
// Build lookup maps for efficient access
const bookByName = pipe(
BIBLE_BOOKS,
Array.map((book) => [book.name.toLowerCase(), book] as const),
Record.fromEntries,
)
const bookByOrder: ReadonlyMap<number, BibleBook> = pipe(
BIBLE_BOOKS,
Array.map((book) => [book.order, book] as const),
(entries) => new Map(entries),
)
// ============================================================================
// Book Normalization Functions
// ============================================================================
/**
* Normalizes a book name to its canonical form.
* Handles abbreviations, Roman numerals, alternate names, etc.
*
* @example
* normalizeBookName("Gen") // => Option.some("Genesis")
* normalizeBookName("1 Cor") // => Option.some("1 Corinthians")
* normalizeBookName("Revelation of John") // => Option.some("Revelation")
* normalizeBookName("I Samuel") // => Option.some("1 Samuel")
* normalizeBookName("invalid") // => Option.none()
*/
export const normalizeBookName = (input: string): Option.Option<BibleBookName> => {
const normalized = pipe(input, String.trim, String.toLowerCase)
// Check exact match first (lowercase canonical names)
const exactMatch = pipe(
bookByName,
Record.get(normalized),
Option.map((book) => book.name),
)
if (Option.isSome(exactMatch)) {
return exactMatch
}
// Check aliases
const aliasMatch = pipe(BOOK_NAME_ALIASES, Record.get(normalized))
if (Option.isSome(aliasMatch)) {
return aliasMatch
}
return Option.none()
}
/**
* Gets book metadata by canonical name.
*
* @example
* getBookByName("Genesis") // => Option.some({ order: 1, name: "Genesis", testament: "old" })
*/
export const getBookByName = (name: string): Option.Option<BibleBook> =>
pipe(
normalizeBookName(name),
Option.flatMap((canonicalName) => pipe(bookByName, Record.get(canonicalName.toLowerCase()))),
)
/**
* Gets book metadata by order number (1-66).
*
* @example
* getBookByOrder(1) // => Option.some({ order: 1, name: "Genesis", testament: "old" })
* getBookByOrder(66) // => Option.some({ order: 66, name: "Revelation", testament: "new" })
*/
export const getBookByOrder = (order: number): Option.Option<BibleBook> =>
pipe(bookByOrder.get(order), Option.fromNullable)
// ============================================================================
// Verse ID Generation & Formatting
// ============================================================================
/**
* Generates a verse ID from book, chapter, and verse.
* The ID is deterministic and can be regenerated from any valid input.
*
* @example
* generateVerseId("Genesis", 1, 1) // => "genesis-1-1"
* generateVerseId("1 Corinthians", 13, 4) // => "1-corinthians-13-4"
* generateVerseId("Song of Solomon", 2, 4) // => "song-of-solomon-2-4"
*/
export const generateVerseId = (book: string, chapter: number, verse: number): string =>
pipe(
normalizeBookName(book),
Option.map((canonicalName) =>
pipe(
canonicalName,
String.toLowerCase,
String.replaceAll(' ', '-'),
(bookSlug) => `${bookSlug}-${chapter}-${verse}`,
),
),
Option.getOrElse(() => {
// Fallback: slugify the input directly
const bookSlug = pipe(book, String.toLowerCase, String.trim, String.replaceAll(' ', '-'))
return `${bookSlug}-${chapter}-${verse}`
}),
)
/**
* Formats a human-readable reference from book, chapter, and verse.
*
* @example
* formatReference("genesis", 1, 1) // => "Genesis 1:1"
* formatReference("1 cor", 13, 4) // => "1 Corinthians 13:4"
*/
export const formatReference = (book: string, chapter: number, verse: number): string =>
pipe(
normalizeBookName(book),
Option.map((canonicalName) => `${canonicalName} ${chapter}:${verse}`),
Option.getOrElse(() => `${book} ${chapter}:${verse}`),
)
/**
* Parses a verse reference string into its components.
* Handles various formats like "Genesis 1:1", "Gen 1:1", "1 Cor 13:4", etc.
*
* @example
* parseReference("Genesis 1:1") // => Option.some({ book: "Genesis", chapter: 1, verse: 1 })
* parseReference("1 Cor 13:4") // => Option.some({ book: "1 Corinthians", chapter: 13, verse: 4 })
* parseReference("invalid") // => Option.none()
*/
export const parseReference = (
reference: string,
): Option.Option<{ book: BibleBookName; chapter: number; verse: number }> => {
// Match patterns like "Genesis 1:1", "1 Cor 13:4", "Song of Solomon 2:4"
// The regex captures: (book name) (chapter):(verse)
const match = reference.match(/^(.+?)\s+(\d+):(\d+)$/)
if (!match) {
return Option.none()
}
const [, bookPart, chapterStr, verseStr] = match
if (!bookPart || !chapterStr || !verseStr) {
return Option.none()
}
return pipe(
normalizeBookName(bookPart),
Option.map((book) => ({
book,
chapter: Number.parseInt(chapterStr, 10),
verse: Number.parseInt(verseStr, 10),
})),
)
}
// ============================================================================
// Chapter/Verse Expansion Functions
// ============================================================================
/**
* Expands compressed chapter data into individual verse records.
*
* @example
* expandChapterToVerses({ book: "Genesis", bookOrder: 1, testament: "old", chapter: 1, startVerse: 1, endVerse: 31 })
* // => [
* // { id: "genesis-1-1", reference: "Genesis 1:1", book: "Genesis", chapter: 1, verse: 1, bookOrder: 1, testament: "old" },
* // { id: "genesis-1-2", reference: "Genesis 1:2", book: "Genesis", chapter: 1, verse: 2, bookOrder: 1, testament: "old" },
* // ... (31 total)
* // ]
*/
export const expandChapterToVerses = (chapter: ChapterData): ReadonlyArray<VerseRecord> =>
pipe(
Array.range(chapter.startVerse, chapter.endVerse),
Array.map((verseNum) => ({
id: generateVerseId(chapter.book, chapter.chapter, verseNum),
reference: formatReference(chapter.book, chapter.chapter, verseNum),
book: chapter.book,
chapter: chapter.chapter,
verse: verseNum,
bookOrder: chapter.bookOrder,
testament: chapter.testament,
})),
)
/**
* Expands all compressed chapter data into individual verse records.
* This is used by the seed script to generate the full 31,102 verse records.
*/
export const expandAllChaptersToVerses = (
chapters: ReadonlyArray<ChapterData>,
): ReadonlyArray<VerseRecord> => pipe(chapters, Array.flatMap(expandChapterToVerses))
// ============================================================================
// Legacy Functions (kept for backwards compatibility)
// ============================================================================
// Regex for parsing verse references - defined at module level for performance
const VERSE_REFERENCE_REGEX = /^([\d\s]*[A-Za-z\s]+?)\s*(\d+):(\d+)$/
/**
* Parse a verse reference like "Matthew 25:31" or "1 John 3:16"
*/
export function parseVerseReference(reference: string): Option.Option<VerseReference> {
return pipe(
reference,
String.trim,
(trimmed) => {
// Match patterns like:
// "Matthew 25:31"
// "1 John 3:16"
// "2 Corinthians 5:10"
const match = trimmed.match(VERSE_REFERENCE_REGEX)
return Option.fromNullable(match)
},
Option.flatMap((match) => {
const book = match[1]
const chapterStr = match[2]
const verseStr = match[3]
if (!(book && chapterStr && verseStr)) {
return Option.none()
}
const chapter = Number.parseInt(chapterStr, 10)
const verse = Number.parseInt(verseStr, 10)
if (Number.isNaN(chapter) || Number.isNaN(verse)) {
return Option.none()
}
return Option.some({
book: pipe(book, String.trim),
chapter,
verse,
})
}),
)
}
/**
* Format a verse reference back to string
*/
export function formatVerseReference(ref: VerseReference): string {
return `${ref.book} ${ref.chapter}:${ref.verse}`
}
/**
* Check if two verse references are equal
*/
export function areVerseReferencesEqual(a: VerseReference, b: VerseReference): boolean {
return (
pipe(a.book, String.toLowerCase) === pipe(b.book, String.toLowerCase) &&
a.chapter === b.chapter &&
a.verse === b.verse
)
}
/**
* Expand a verse range into individual verse references
* Returns None if the range is invalid (different books, invalid chapter/verse progression)
*/
export function expandVerseRange(
start: VerseReference,
end: VerseReference,
): Option.Option<Array<VerseReference>> {
// Must be same book
if (pipe(start.book, String.toLowerCase) !== pipe(end.book, String.toLowerCase)) {
return Option.none()
}
// Same chapter - expand verses
if (start.chapter === end.chapter) {
if (start.verse > end.verse) {
return Option.none() // Invalid range
}
return pipe(
Array.range(start.verse, end.verse),
Array.map((verse) => ({
book: start.book,
chapter: start.chapter,
verse,
})),
Option.some,
)
}
// Multiple chapters
if (start.chapter > end.chapter) {
return Option.none() // Invalid range
}
// We can't know how many verses are in each chapter without a Bible structure database
// For now, we'll just return the start and end verses as-is
// A more complete implementation would need verse count data per chapter
return Option.some([start, end])
}
/**
* Parse a verse range string like "Matthew 25:31 - Matthew 25:34"
* Also handles single verses like "Matthew 25:31"
*/
export function parseVerseRange(rangeStr: string): Option.Option<VerseRange> {
const rangeSeparators = ['-', '–', '—'] // Regular dash, en-dash, em-dash
return pipe(rangeStr, String.trim, (trimmed) => {
// Check if it's a range (contains a dash/hyphen)
const separator = pipe(
rangeSeparators,
Array.findFirst((sep) => pipe(trimmed, String.includes(sep))),
)
return pipe(
separator,
Option.match({
onNone: () => {
// It's a single verse
return pipe(
parseVerseReference(trimmed),
Option.map((verse) => ({
end: verse,
start: verse,
})),
)
},
onSome: (sep) => {
// It's a range
const parts = pipe(trimmed, String.split(sep), Array.map(String.trim))
if (pipe(parts, Array.length) !== 2) {
return Option.none()
}
const start = pipe(parts, Array.unsafeGet(0), parseVerseReference)
const end = pipe(parts, Array.unsafeGet(1), parseVerseReference)
return pipe(
start,
Option.flatMap((s) =>
pipe(
end,
Option.map((e) => ({ end: e, start: s })),
),
),
)
},
}),
)
})
}
/**
* Check if a verse range represents a single verse
*/
export function isSingleVerse(range: VerseRange): boolean {
return areVerseReferencesEqual(range.start, range.end)
}
/**
* Expand verse range strings into individual verse strings
* Example: "Matthew 25:31 - Matthew 25:34" -> ["Matthew 25:31", "Matthew 25:32", "Matthew 25:33", "Matthew 25:34"]
* Returns None if the input is invalid
*/
export function expandVerseRangeString(rangeStr: string): Option.Option<Array<string>> {
return pipe(
parseVerseRange(rangeStr),
Option.flatMap((range) => {
// If it's a single verse, return as-is
if (isSingleVerse(range)) {
return Option.some([formatVerseReference(range.start)])
}
// Expand the range
return pipe(
expandVerseRange(range.start, range.end),
Option.map(Array.map(formatVerseReference)),
)
}),
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment