rv-bible-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/dist/db.d.ts +63 -0
- package/dist/db.js +153 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.js +258 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +561 -0
- package/dist/parser.d.ts +20 -0
- package/dist/parser.js +320 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +30 -0
- package/dist/ui/Pager.d.ts +5 -0
- package/dist/ui/Pager.js +999 -0
- package/dist/ui/nav.d.ts +6 -0
- package/dist/ui/nav.js +37 -0
- package/package.json +60 -0
- package/rv.db +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# rv — Recovery Version Bible CLI
|
|
2
|
+
|
|
3
|
+
A fast, study-grade terminal Bible reader for the Recovery Version. Offline-first, footnote-aware, cross-reference deep-linking, clipboard-friendly.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install -g rv-bible-cli
|
|
7
|
+
rv
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
rv john 3 # read a chapter (interactive pager)
|
|
14
|
+
rv john 3:16 # read a single verse
|
|
15
|
+
rv search grace # concordance search (1,129 curated words)
|
|
16
|
+
rv help # see all commands and shortcuts
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
### Reading
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
rv john 3 # chapter mode — opens full-screen pager
|
|
25
|
+
rv john 3:16 # single verse
|
|
26
|
+
rv john 3:16-18 # verse range
|
|
27
|
+
rv john 3:16,18,20 # specific verses
|
|
28
|
+
rv john 3:16 rom 8:28 # verses across books
|
|
29
|
+
rv jn 3 # fuzzy book names (jn, gen, rm, eph, rev, etc.)
|
|
30
|
+
rv john 3 --notes # inline footnote markers + footnote block
|
|
31
|
+
rv john 3 --outline # section headers inline
|
|
32
|
+
rv john 3 --full # both notes and outline
|
|
33
|
+
rv john 3 --raw # plain text, no pager (also auto when piped)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Footnotes
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
rv note john 3:16 # all footnotes for a verse
|
|
40
|
+
rv note john 3:16 2d # specific footnote by marker
|
|
41
|
+
rv note john 3:14-16 # footnotes for a range
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Search
|
|
45
|
+
|
|
46
|
+
Concordance-first for known words (1,129 curated entries), FTS5 full-text for everything else.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
rv search grace # concordance lookup
|
|
50
|
+
rv search grace in romans # scoped to a book
|
|
51
|
+
rv search "only begotten" # phrase match
|
|
52
|
+
rv search eternal life # multi-word AND
|
|
53
|
+
rv search grace --fts # force raw FTS5
|
|
54
|
+
rv search grace --all # show all results (default: 20/page)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Copy to Clipboard
|
|
58
|
+
|
|
59
|
+
Add `--copy` to any read command. Output displays normally AND goes to clipboard.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rv john 3:16 --copy # "John 3:16 For God so loved..."
|
|
63
|
+
rv john 3:16 --copy --no-ref # plain text only
|
|
64
|
+
rv john 3:16 --copy --numbered # "16 For God so loved..."
|
|
65
|
+
rv john 3:16 --copy --md # markdown block quote
|
|
66
|
+
rv search grace --copy # copy all results
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Navigation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
rv # home screen — browse books, pick chapters
|
|
73
|
+
rv continue # resume last-read chapter
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Pager
|
|
77
|
+
|
|
78
|
+
Chapter mode in a terminal automatically launches a full-screen interactive pager.
|
|
79
|
+
|
|
80
|
+
### Normal Mode
|
|
81
|
+
|
|
82
|
+
| Key | Action |
|
|
83
|
+
|-----|--------|
|
|
84
|
+
| `j` / `k` / arrows | scroll up/down |
|
|
85
|
+
| `Space` / `b` | page down/up |
|
|
86
|
+
| `g` / `G` | top / bottom |
|
|
87
|
+
| `n` / `p` / left/right | next / prev chapter (cross-book) |
|
|
88
|
+
| `f` | toggle footnotes |
|
|
89
|
+
| `o` | toggle outline |
|
|
90
|
+
| `d` | enter study mode |
|
|
91
|
+
| `c` | enter copy mode |
|
|
92
|
+
| `/` | search within chapter |
|
|
93
|
+
| `:` | goto — type a reference (e.g. `rom 8`) |
|
|
94
|
+
| `[` / `]` | back / forward (navigation history) |
|
|
95
|
+
| `H` | home screen |
|
|
96
|
+
| `q` / `Esc` | quit |
|
|
97
|
+
|
|
98
|
+
### Study Mode (`d`)
|
|
99
|
+
|
|
100
|
+
Browse footnotes and follow cross-references. The footnote panel updates dynamically as you move between verses.
|
|
101
|
+
|
|
102
|
+
| Key | Action |
|
|
103
|
+
|-----|--------|
|
|
104
|
+
| up/down | move verse cursor |
|
|
105
|
+
| left/right | prev / next chapter |
|
|
106
|
+
| type number | follow a numbered cross-reference |
|
|
107
|
+
| `v` | start range selection |
|
|
108
|
+
| `c` | copy verse or range |
|
|
109
|
+
| `/` | search within chapter |
|
|
110
|
+
| `:` | goto |
|
|
111
|
+
| `[` / `]` | back / forward |
|
|
112
|
+
| `d` / `Esc` | exit study mode |
|
|
113
|
+
|
|
114
|
+
Study mode persists across navigation — follow a cross-ref and you stay in study mode, landing on the exact target verse. Press `[` to go back.
|
|
115
|
+
|
|
116
|
+
### Copy Mode (`c`)
|
|
117
|
+
|
|
118
|
+
| Key | Action |
|
|
119
|
+
|-----|--------|
|
|
120
|
+
| up/down | move verse cursor |
|
|
121
|
+
| `v` | start range selection |
|
|
122
|
+
| `Enter` / `c` | copy to clipboard |
|
|
123
|
+
| `Esc` | cancel |
|
|
124
|
+
|
|
125
|
+
### Home Screen
|
|
126
|
+
|
|
127
|
+
Press `H` from the pager or run `rv` with no arguments.
|
|
128
|
+
|
|
129
|
+
| Key | Action |
|
|
130
|
+
|-----|--------|
|
|
131
|
+
| arrows | navigate the book grid |
|
|
132
|
+
| type a letter | jump to first matching book (repeat to cycle) |
|
|
133
|
+
| `Enter` | select book, then pick chapter |
|
|
134
|
+
| `Space` | continue last-read chapter |
|
|
135
|
+
| `q` | quit |
|
|
136
|
+
|
|
137
|
+
## Database
|
|
138
|
+
|
|
139
|
+
The bundled SQLite database (`rv.db`, 43 MB) contains:
|
|
140
|
+
|
|
141
|
+
| Content | Count |
|
|
142
|
+
|---------|-------|
|
|
143
|
+
| Books | 66 |
|
|
144
|
+
| Verses | 31,220 |
|
|
145
|
+
| Footnotes | 47,436 |
|
|
146
|
+
| Cross-references | 199,369 |
|
|
147
|
+
| Section headers | 2,907 |
|
|
148
|
+
| Concordance entries | ~110,000 |
|
|
149
|
+
|
|
150
|
+
All data is from the Recovery Version Bible. Offline — no network requests.
|
|
151
|
+
|
|
152
|
+
## Requirements
|
|
153
|
+
|
|
154
|
+
- Node.js 18+
|
|
155
|
+
- A terminal that supports ANSI colors
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
For personal study use.
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ParsedRef } from './parser.js';
|
|
2
|
+
export interface Verse {
|
|
3
|
+
book: string;
|
|
4
|
+
chapter: number;
|
|
5
|
+
verse: string;
|
|
6
|
+
text: string;
|
|
7
|
+
outline_anchor: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Footnote {
|
|
10
|
+
book: string;
|
|
11
|
+
chapter: number;
|
|
12
|
+
verse: string;
|
|
13
|
+
marker: string;
|
|
14
|
+
anchor: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SectionHeader {
|
|
18
|
+
id: number;
|
|
19
|
+
book: string;
|
|
20
|
+
outline_anchor: string;
|
|
21
|
+
level: number;
|
|
22
|
+
label: string;
|
|
23
|
+
text: string;
|
|
24
|
+
verse_start: string;
|
|
25
|
+
verse_end: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface BookInfo {
|
|
28
|
+
abbr: string;
|
|
29
|
+
full_name: string;
|
|
30
|
+
testament: string;
|
|
31
|
+
category: string;
|
|
32
|
+
sort_order: number;
|
|
33
|
+
author: string;
|
|
34
|
+
date_written: string;
|
|
35
|
+
place_of_writing: string;
|
|
36
|
+
time_period: string;
|
|
37
|
+
recipient: string;
|
|
38
|
+
subject_short: string;
|
|
39
|
+
subject_full: string;
|
|
40
|
+
intro_text: string;
|
|
41
|
+
chapter_count: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function getChapter(book: string, chapter: number): Verse[];
|
|
44
|
+
export declare function getVerse(book: string, chapter: number, verse: string): Verse | null;
|
|
45
|
+
export declare function getVerseRange(book: string, chapter: number, start: string, end: string): Verse[];
|
|
46
|
+
export declare function getVersesByRef(ref: ParsedRef): Verse[];
|
|
47
|
+
export declare function getVersesByRefList(refs: ParsedRef[]): Verse[];
|
|
48
|
+
export declare function getSectionHeaders(book: string): SectionHeader[];
|
|
49
|
+
export declare function getFootnote(book: string, chapter: number, verse: string, marker: string): Footnote | null;
|
|
50
|
+
export declare function getFootnotesForChapter(book: string, chapter: number): Footnote[];
|
|
51
|
+
export declare function getFootnotesForVerses(book: string, chapter: number, verseIds: string[]): Footnote[];
|
|
52
|
+
export declare function getBookInfo(book: string): BookInfo | null;
|
|
53
|
+
export declare function getAllBooks(): BookInfo[];
|
|
54
|
+
export interface CrossRef {
|
|
55
|
+
src_marker: string;
|
|
56
|
+
tgt_book: string;
|
|
57
|
+
tgt_chapter: number;
|
|
58
|
+
tgt_verse: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function getCrossRefsForVerse(book: string, chapter: number, verse: string): CrossRef[];
|
|
61
|
+
export declare function isInConcordance(word: string): boolean;
|
|
62
|
+
export declare function getTopicVerses(word: string, book?: string): Verse[];
|
|
63
|
+
export declare function searchFTS(queryStr: string, book?: string): Verse[];
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import initSqlJs from 'sql.js';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Connection — sql.js (pure JS/WASM, no native compilation)
|
|
6
|
+
// Top-level await: module consumers wait for DB to be ready before importing
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
const SQL = await initSqlJs();
|
|
9
|
+
const dbPath = path.join(import.meta.dirname, '../rv.db');
|
|
10
|
+
const dbBuffer = readFileSync(dbPath);
|
|
11
|
+
const db = new SQL.Database(dbBuffer);
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Query helpers — typed wrappers over sql.js statement API
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function query(sql, params = []) {
|
|
16
|
+
const stmt = db.prepare(sql);
|
|
17
|
+
if (params.length > 0)
|
|
18
|
+
stmt.bind(params);
|
|
19
|
+
const results = [];
|
|
20
|
+
while (stmt.step()) {
|
|
21
|
+
results.push(stmt.getAsObject());
|
|
22
|
+
}
|
|
23
|
+
stmt.free();
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
function queryOne(sql, params = []) {
|
|
27
|
+
const results = query(sql, params);
|
|
28
|
+
return results[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Query functions
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export function getChapter(book, chapter) {
|
|
34
|
+
return query('SELECT book, chapter, verse, text, outline_anchor FROM verses WHERE book = ? AND chapter = ? ORDER BY rowid', [book, chapter]);
|
|
35
|
+
}
|
|
36
|
+
export function getVerse(book, chapter, verse) {
|
|
37
|
+
return queryOne('SELECT book, chapter, verse, text, outline_anchor FROM verses WHERE book = ? AND chapter = ? AND verse = ?', [book, chapter, verse]);
|
|
38
|
+
}
|
|
39
|
+
// Returns verses between start and end (inclusive) in rowid order.
|
|
40
|
+
// Handles sub-verses: range '1-2' includes '2a' and '2b' if '2' doesn't exist.
|
|
41
|
+
export function getVerseRange(book, chapter, start, end) {
|
|
42
|
+
const all = getChapter(book, chapter);
|
|
43
|
+
const findFirst = (target) => all.findIndex(v => v.verse === target || v.verse.startsWith(target));
|
|
44
|
+
const findLast = (target) => {
|
|
45
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
46
|
+
const v = all[i];
|
|
47
|
+
if (v.verse === target || v.verse.startsWith(target))
|
|
48
|
+
return i;
|
|
49
|
+
}
|
|
50
|
+
return -1;
|
|
51
|
+
};
|
|
52
|
+
const startIdx = findFirst(start);
|
|
53
|
+
const endIdx = findLast(end);
|
|
54
|
+
if (startIdx === -1 || endIdx === -1 || startIdx > endIdx)
|
|
55
|
+
return [];
|
|
56
|
+
return all.slice(startIdx, endIdx + 1);
|
|
57
|
+
}
|
|
58
|
+
// Resolves a ParsedRef to its matching verses. Chapter-only refs return all verses.
|
|
59
|
+
export function getVersesByRef(ref) {
|
|
60
|
+
if (!ref.verses) {
|
|
61
|
+
return getChapter(ref.book, ref.chapter);
|
|
62
|
+
}
|
|
63
|
+
const { verses } = ref;
|
|
64
|
+
if (verses.type === 'single') {
|
|
65
|
+
const v = getVerse(ref.book, ref.chapter, verses.verse);
|
|
66
|
+
return v ? [v] : [];
|
|
67
|
+
}
|
|
68
|
+
if (verses.type === 'range') {
|
|
69
|
+
return getVerseRange(ref.book, ref.chapter, verses.start, verses.end);
|
|
70
|
+
}
|
|
71
|
+
// list
|
|
72
|
+
return verses.verses
|
|
73
|
+
.map(v => getVerse(ref.book, ref.chapter, v))
|
|
74
|
+
.filter((v) => v !== null);
|
|
75
|
+
}
|
|
76
|
+
// Resolves a list of ParsedRefs to a flat array of verses. No deduplication.
|
|
77
|
+
export function getVersesByRefList(refs) {
|
|
78
|
+
return refs.flatMap(ref => getVersesByRef(ref));
|
|
79
|
+
}
|
|
80
|
+
export function getSectionHeaders(book) {
|
|
81
|
+
return query('SELECT id, book, outline_anchor, level, label, text, verse_start, verse_end FROM section_headers WHERE book = ? ORDER BY id', [book]);
|
|
82
|
+
}
|
|
83
|
+
export function getFootnote(book, chapter, verse, marker) {
|
|
84
|
+
return queryOne('SELECT book, chapter, verse, marker, anchor, text FROM footnotes WHERE book = ? AND chapter = ? AND verse = ? AND marker = ?', [book, chapter, verse, marker]);
|
|
85
|
+
}
|
|
86
|
+
export function getFootnotesForChapter(book, chapter) {
|
|
87
|
+
return query('SELECT book, chapter, verse, marker, anchor, text FROM footnotes WHERE book = ? AND chapter = ? ORDER BY rowid', [book, chapter]);
|
|
88
|
+
}
|
|
89
|
+
// Returns footnotes only for the given set of verse ids
|
|
90
|
+
export function getFootnotesForVerses(book, chapter, verseIds) {
|
|
91
|
+
if (verseIds.length === 0)
|
|
92
|
+
return [];
|
|
93
|
+
const placeholders = verseIds.map(() => '?').join(', ');
|
|
94
|
+
return query(`SELECT book, chapter, verse, marker, anchor, text FROM footnotes
|
|
95
|
+
WHERE book = ? AND chapter = ? AND verse IN (${placeholders}) ORDER BY rowid`, [book, chapter, ...verseIds]);
|
|
96
|
+
}
|
|
97
|
+
export function getBookInfo(book) {
|
|
98
|
+
return queryOne('SELECT * FROM book_info WHERE abbr = ?', [book]);
|
|
99
|
+
}
|
|
100
|
+
export function getAllBooks() {
|
|
101
|
+
return query('SELECT * FROM book_info ORDER BY sort_order');
|
|
102
|
+
}
|
|
103
|
+
// Returns cross-refs for all footnote markers on a given verse.
|
|
104
|
+
// Filters out self-references (where target = source verse).
|
|
105
|
+
export function getCrossRefsForVerse(book, chapter, verse) {
|
|
106
|
+
const all = query(`SELECT src_marker, tgt_book, tgt_chapter, tgt_verse
|
|
107
|
+
FROM cross_refs WHERE src_book = ? AND src_chapter = ? AND src_verse = ?
|
|
108
|
+
ORDER BY rowid`, [book, chapter, verse]);
|
|
109
|
+
return all.filter(r => !(r.tgt_book === book && r.tgt_chapter === chapter && r.tgt_verse === verse));
|
|
110
|
+
}
|
|
111
|
+
// ── Search & Topic ───────────────────────────────────────────────────────────
|
|
112
|
+
export function isInConcordance(word) {
|
|
113
|
+
return (queryOne('SELECT COUNT(*) as cnt FROM concordance WHERE word = ?', [word.toLowerCase()])?.cnt ?? 0) > 0;
|
|
114
|
+
}
|
|
115
|
+
export function getTopicVerses(word, book) {
|
|
116
|
+
const w = word.toLowerCase();
|
|
117
|
+
if (book) {
|
|
118
|
+
return query(`SELECT v.book, v.chapter, v.verse, v.text, v.outline_anchor
|
|
119
|
+
FROM concordance c JOIN verses v USING (book, chapter, verse)
|
|
120
|
+
WHERE c.word = ? AND c.book = ?
|
|
121
|
+
ORDER BY v.rowid`, [w, book]);
|
|
122
|
+
}
|
|
123
|
+
return query(`SELECT v.book, v.chapter, v.verse, v.text, v.outline_anchor
|
|
124
|
+
FROM concordance c JOIN verses v USING (book, chapter, verse)
|
|
125
|
+
WHERE c.word = ?
|
|
126
|
+
ORDER BY v.rowid`, [w]);
|
|
127
|
+
}
|
|
128
|
+
// Full-text search using LIKE (sql.js doesn't include FTS5).
|
|
129
|
+
// Parses the FTS5-style query into LIKE conditions:
|
|
130
|
+
// '"grace"' → text LIKE '%grace%'
|
|
131
|
+
// '"eternal" AND "life"' → text LIKE '%eternal%' AND text LIKE '%life%'
|
|
132
|
+
// Returns [] on parse failure rather than throwing.
|
|
133
|
+
export function searchFTS(queryStr, book) {
|
|
134
|
+
try {
|
|
135
|
+
// Extract quoted terms from FTS5-style query
|
|
136
|
+
const terms = [...queryStr.matchAll(/"([^"]+)"/g)].map(m => m[1]);
|
|
137
|
+
if (terms.length === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const likeClauses = terms.map(() => 'text LIKE ?').join(' AND ');
|
|
140
|
+
const likeParams = terms.map(t => `%${t}%`);
|
|
141
|
+
if (book) {
|
|
142
|
+
return query(`SELECT book, chapter, verse, text, outline_anchor FROM verses
|
|
143
|
+
WHERE ${likeClauses} AND book = ?
|
|
144
|
+
ORDER BY rowid LIMIT 500`, [...likeParams, book]);
|
|
145
|
+
}
|
|
146
|
+
return query(`SELECT book, chapter, verse, text, outline_anchor FROM verses
|
|
147
|
+
WHERE ${likeClauses}
|
|
148
|
+
ORDER BY rowid LIMIT 500`, likeParams);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Verse, Footnote, SectionHeader } from './db.js';
|
|
2
|
+
export declare function toSuperscript(s: string): string;
|
|
3
|
+
export declare function stripMarkers(text: string): string;
|
|
4
|
+
export declare function highlightTerms(text: string, terms: string[]): string;
|
|
5
|
+
export declare function renderVerseInline(verse: Verse, bookName: string, notes: boolean): string;
|
|
6
|
+
export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
|
|
7
|
+
export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
|
|
8
|
+
export declare function renderNoteDisplay(verse: Verse, fn: Footnote, bookName: string): string;
|
|
9
|
+
export interface RenderOpts {
|
|
10
|
+
notes: boolean;
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function renderVerses(verses: Verse[], headers: SectionHeader[], footnotes: Footnote[], opts: RenderOpts): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
// ── Superscript helpers ──────────────────────────────────────────────────────
|
|
3
|
+
const SUPER_DIGITS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
|
|
4
|
+
const SUPER_LOWER = {
|
|
5
|
+
a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ',
|
|
6
|
+
h: 'ʰ', i: 'ⁱ', j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ',
|
|
7
|
+
o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ',
|
|
8
|
+
w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ',
|
|
9
|
+
};
|
|
10
|
+
// Converts "16" → "¹⁶", "2a" → "²ᵃ", "1a" → "¹ᵃ"
|
|
11
|
+
export function toSuperscript(s) {
|
|
12
|
+
return s.split('').map(c => {
|
|
13
|
+
if (/[0-9]/.test(c))
|
|
14
|
+
return SUPER_DIGITS[parseInt(c)] ?? c;
|
|
15
|
+
return SUPER_LOWER[c] ?? c;
|
|
16
|
+
}).join('');
|
|
17
|
+
}
|
|
18
|
+
// ── Verse text processing ────────────────────────────────────────────────────
|
|
19
|
+
// Strips [marker] tokens entirely — clean reading mode (default)
|
|
20
|
+
export function stripMarkers(text) {
|
|
21
|
+
return text.replace(/\[[^\]]+\]/g, '');
|
|
22
|
+
}
|
|
23
|
+
// Escape special regex chars — used by highlightTerms
|
|
24
|
+
function escapeRegex(s) {
|
|
25
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
// Bold + yellow highlight for search/topic term matches in stripped verse text.
|
|
28
|
+
export function highlightTerms(text, terms) {
|
|
29
|
+
if (terms.length === 0)
|
|
30
|
+
return text;
|
|
31
|
+
let result = text;
|
|
32
|
+
for (const term of terms) {
|
|
33
|
+
const re = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi');
|
|
34
|
+
result = result.replace(re, chalk.bold.yellow('$1'));
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
// Inline format for verse-level queries: "John 3:16 For God so loved..."
|
|
39
|
+
// Used when displaying specific verses rather than a full chapter.
|
|
40
|
+
export function renderVerseInline(verse, bookName, notes) {
|
|
41
|
+
const label = chalk.bold(`${bookName} ${verse.chapter}:${verse.verse}`);
|
|
42
|
+
const text = notes ? highlightMarkers(verse.text) : stripMarkers(verse.text);
|
|
43
|
+
return `${label} ${text}`;
|
|
44
|
+
}
|
|
45
|
+
// Renders [marker] tokens as styled superscript — notes mode
|
|
46
|
+
function highlightMarkers(text) {
|
|
47
|
+
return text.replace(/\[([^\]]+)\]/g, (_, m) => chalk.cyan(toSuperscript(m)));
|
|
48
|
+
}
|
|
49
|
+
// ── Section header rendering ─────────────────────────────────────────────────
|
|
50
|
+
// Margin + 2 spaces per level, starting at level 1
|
|
51
|
+
function headerIndent(level) {
|
|
52
|
+
return margin() + ' '.repeat(Math.min(level, 5));
|
|
53
|
+
}
|
|
54
|
+
function renderHeader(h) {
|
|
55
|
+
const indent = headerIndent(h.level);
|
|
56
|
+
const label = `${h.label}.`;
|
|
57
|
+
const range = h.verse_end
|
|
58
|
+
? chalk.dim(` (${h.verse_start}–${h.verse_end})`)
|
|
59
|
+
: '';
|
|
60
|
+
if (h.level === 1) {
|
|
61
|
+
return chalk.bold(`${indent}${label} ${h.text}`) + range;
|
|
62
|
+
}
|
|
63
|
+
if (h.level <= 3) {
|
|
64
|
+
return `${indent}${chalk.dim(label)} ${h.text}${range}`;
|
|
65
|
+
}
|
|
66
|
+
return chalk.dim(`${indent}${label} ${h.text}`) + range;
|
|
67
|
+
}
|
|
68
|
+
// Groups section headers by outline_anchor for fast lookup during verse rendering
|
|
69
|
+
function buildHeaderMap(headers) {
|
|
70
|
+
const map = new Map();
|
|
71
|
+
for (const h of headers) {
|
|
72
|
+
const bucket = map.get(h.outline_anchor) ?? [];
|
|
73
|
+
bucket.push(h);
|
|
74
|
+
map.set(h.outline_anchor, bucket);
|
|
75
|
+
}
|
|
76
|
+
return map;
|
|
77
|
+
}
|
|
78
|
+
// ── Layout constants ────────────────────────────────────────────────────────
|
|
79
|
+
const LEFT_MARGIN = 6; // extra left padding for a book-like feel
|
|
80
|
+
const MAX_TEXT_WIDTH = 90; // cap text block width on wide terminals
|
|
81
|
+
// Effective content width: terminal width minus margins, capped at MAX_TEXT_WIDTH
|
|
82
|
+
function contentWidth() {
|
|
83
|
+
const termWidth = process.stdout.columns ?? 80;
|
|
84
|
+
return Math.min(termWidth - LEFT_MARGIN, MAX_TEXT_WIDTH);
|
|
85
|
+
}
|
|
86
|
+
// Left margin string
|
|
87
|
+
function margin() {
|
|
88
|
+
return ' '.repeat(LEFT_MARGIN);
|
|
89
|
+
}
|
|
90
|
+
// ── Visible-length helper ────────────────────────────────────────────────────
|
|
91
|
+
// Strip ANSI escape codes before measuring — needed for word-wrap width checks
|
|
92
|
+
// on text that may contain chalk styling (e.g. highlighted markers in notes mode).
|
|
93
|
+
function visibleLength(s) {
|
|
94
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
95
|
+
}
|
|
96
|
+
// ── Word-wrap helper ─────────────────────────────────────────────────────────
|
|
97
|
+
// Wraps plain `text` to `maxWidth` columns. Returns array of line strings.
|
|
98
|
+
// Use visibleLength=true when words may contain ANSI escape codes.
|
|
99
|
+
function wrapWords(text, maxWidth, ansi = false) {
|
|
100
|
+
if (maxWidth < 20)
|
|
101
|
+
return [text];
|
|
102
|
+
const words = text.split(' ');
|
|
103
|
+
const textLines = [];
|
|
104
|
+
let current = '';
|
|
105
|
+
let currentW = 0;
|
|
106
|
+
for (const word of words) {
|
|
107
|
+
const wLen = ansi ? visibleLength(word) : word.length;
|
|
108
|
+
if (current === '') {
|
|
109
|
+
current = word;
|
|
110
|
+
currentW = wLen;
|
|
111
|
+
}
|
|
112
|
+
else if (currentW + 1 + wLen <= maxWidth) {
|
|
113
|
+
current += ' ' + word;
|
|
114
|
+
currentW += 1 + wLen;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
textLines.push(current);
|
|
118
|
+
current = word;
|
|
119
|
+
currentW = wLen;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (current)
|
|
123
|
+
textLines.push(current);
|
|
124
|
+
return textLines;
|
|
125
|
+
}
|
|
126
|
+
// ── Single verse line ────────────────────────────────────────────────────────
|
|
127
|
+
// numWidth: max superscript width of all verse numbers in the set — used to
|
|
128
|
+
// right-align numbers so verse text always starts at the same column.
|
|
129
|
+
// Wraps long lines at terminal width with a hanging indent so continuation
|
|
130
|
+
// lines re-align with the text start, not the terminal edge.
|
|
131
|
+
function renderVerse(verse, notes, numWidth = 1) {
|
|
132
|
+
const numStr = toSuperscript(verse.verse);
|
|
133
|
+
const pad = ' '.repeat(Math.max(0, numWidth - numStr.length));
|
|
134
|
+
const num = chalk.dim(numStr);
|
|
135
|
+
const rawText = notes ? highlightMarkers(verse.text) : stripMarkers(verse.text);
|
|
136
|
+
const m = margin();
|
|
137
|
+
const localPrefixLen = 2 + numWidth + 1; // verse num + spacing within margin
|
|
138
|
+
const textWidth = contentWidth() - localPrefixLen;
|
|
139
|
+
const linePrefix = `${m}${pad}${num} `;
|
|
140
|
+
const continuation = `${m}${' '.repeat(localPrefixLen)}`;
|
|
141
|
+
if (textWidth < 20)
|
|
142
|
+
return `${linePrefix}${rawText}`;
|
|
143
|
+
const textLines = wrapWords(rawText, textWidth, true);
|
|
144
|
+
const first = `${linePrefix}${textLines[0] ?? ''}`;
|
|
145
|
+
const rest = textLines.slice(1).map(l => `${continuation}${l}`);
|
|
146
|
+
return [first, ...rest].join('\n');
|
|
147
|
+
}
|
|
148
|
+
// ── Footnote block ───────────────────────────────────────────────────────────
|
|
149
|
+
// Paragraph continuation markers (e.g. 1P1, 1P2) extend the base note but
|
|
150
|
+
// don't appear inline in verse text. Skip them when rendering the block —
|
|
151
|
+
// the base marker already covers the note.
|
|
152
|
+
function isParagraphContinuation(marker) {
|
|
153
|
+
return /P\d+$/.test(marker);
|
|
154
|
+
}
|
|
155
|
+
export function renderFootnoteBlock(footnotes) {
|
|
156
|
+
// Deduplicate by anchor (same text can appear under multiple markers)
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const unique = footnotes.filter(f => {
|
|
159
|
+
if (isParagraphContinuation(f.marker))
|
|
160
|
+
return false;
|
|
161
|
+
if (seen.has(f.anchor))
|
|
162
|
+
return false;
|
|
163
|
+
seen.add(f.anchor);
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
if (unique.length === 0)
|
|
167
|
+
return '';
|
|
168
|
+
const m = margin();
|
|
169
|
+
const cw = contentWidth();
|
|
170
|
+
const sep = m + chalk.dim('─'.repeat(cw));
|
|
171
|
+
const lines = [sep];
|
|
172
|
+
for (const fn of unique) {
|
|
173
|
+
const markerStr = toSuperscript(fn.marker);
|
|
174
|
+
const localPrefixLen = 2 + markerStr.length + 1;
|
|
175
|
+
const linePrefix = `${m}${chalk.cyan(markerStr)} `;
|
|
176
|
+
const continuation = `${m}${' '.repeat(localPrefixLen)}`;
|
|
177
|
+
const textLines = wrapWords(fn.text, cw - localPrefixLen);
|
|
178
|
+
const first = `${linePrefix}${chalk.dim(textLines[0] ?? '')}`;
|
|
179
|
+
const rest = textLines.slice(1).map(l => `${continuation}${chalk.dim(l)}`);
|
|
180
|
+
lines.push([first, ...rest].join('\n'));
|
|
181
|
+
}
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
// ── Note display (rv note <ref> <marker>) ────────────────────────────────────
|
|
185
|
+
// All inline footnotes for one verse — used when no marker is specified.
|
|
186
|
+
export function renderNoteDisplayAll(verse, footnotes, bookName) {
|
|
187
|
+
const title = chalk.bold(`${bookName} ${verse.chapter}:${verse.verse}`);
|
|
188
|
+
const verseLine = renderVerse(verse, true);
|
|
189
|
+
if (footnotes.length === 0) {
|
|
190
|
+
return [title, '', verseLine].join('\n');
|
|
191
|
+
}
|
|
192
|
+
return [title, '', verseLine, '', renderFootnoteBlock(footnotes)].join('\n');
|
|
193
|
+
}
|
|
194
|
+
export function renderNoteDisplay(verse, fn, bookName) {
|
|
195
|
+
const title = chalk.bold(`${bookName} ${verse.chapter}:${verse.verse}`) +
|
|
196
|
+
chalk.dim(` note ${fn.marker}`);
|
|
197
|
+
const verseLine = renderVerse(verse, true); // show markers so user sees where note attaches
|
|
198
|
+
const markerStr = toSuperscript(fn.marker);
|
|
199
|
+
const prefixLen = 2 + markerStr.length + 1;
|
|
200
|
+
const continuation = ' '.repeat(prefixLen);
|
|
201
|
+
const textLines = wrapWords(fn.text, (process.stdout.columns ?? 80) - prefixLen);
|
|
202
|
+
const noteLine = [` ${chalk.cyan(markerStr)} ${textLines[0] ?? ''}`,
|
|
203
|
+
...textLines.slice(1).map(l => `${continuation}${l}`)].join('\n');
|
|
204
|
+
return [title, '', verseLine, '', noteLine].join('\n');
|
|
205
|
+
}
|
|
206
|
+
export function renderVerses(verses, headers, footnotes, opts) {
|
|
207
|
+
const lines = [];
|
|
208
|
+
const headerMap = buildHeaderMap(headers);
|
|
209
|
+
let lastAnchor = null;
|
|
210
|
+
let verseCount = 0;
|
|
211
|
+
const maxNumWidth = verses.length > 0
|
|
212
|
+
? Math.max(...verses.map(v => toSuperscript(v.verse).length))
|
|
213
|
+
: 1;
|
|
214
|
+
if (opts.title) {
|
|
215
|
+
const m = margin();
|
|
216
|
+
lines.push(`${m}${' '.repeat(2 + maxNumWidth + 1)}${chalk.bold(opts.title)}`);
|
|
217
|
+
lines.push('');
|
|
218
|
+
}
|
|
219
|
+
for (const verse of verses) {
|
|
220
|
+
// Insert section header(s) when we enter a new outline section
|
|
221
|
+
const anchor = verse.outline_anchor;
|
|
222
|
+
if (anchor && anchor !== lastAnchor) {
|
|
223
|
+
const hdrs = headerMap.get(anchor);
|
|
224
|
+
if (hdrs) {
|
|
225
|
+
if (lines.length > 0 && lines[lines.length - 1] !== '')
|
|
226
|
+
lines.push('');
|
|
227
|
+
// Dim separator before top-level (Roman numeral) section breaks, after at least one verse
|
|
228
|
+
if (verseCount > 0 && hdrs.some(h => h.level === 1)) {
|
|
229
|
+
lines.push(margin() + chalk.dim('─'.repeat(40)));
|
|
230
|
+
}
|
|
231
|
+
for (const h of hdrs)
|
|
232
|
+
lines.push(renderHeader(h));
|
|
233
|
+
lines.push('');
|
|
234
|
+
}
|
|
235
|
+
lastAnchor = anchor;
|
|
236
|
+
}
|
|
237
|
+
lines.push(renderVerse(verse, opts.notes, maxNumWidth));
|
|
238
|
+
verseCount++;
|
|
239
|
+
}
|
|
240
|
+
if (opts.notes && footnotes.length > 0) {
|
|
241
|
+
// Only show footnotes whose markers actually appear inline in the displayed
|
|
242
|
+
// verses. This prevents duplicates where the same note is stored under
|
|
243
|
+
// multiple markers (e.g. '2', 'd', and '2d' for the same footnote — only
|
|
244
|
+
// '2d' appears in the verse text so only that one is shown).
|
|
245
|
+
const inlineMarkers = new Set();
|
|
246
|
+
for (const v of verses) {
|
|
247
|
+
for (const m of v.text.matchAll(/\[([^\]]+)\]/g)) {
|
|
248
|
+
inlineMarkers.add(m[1]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const scoped = footnotes.filter(f => inlineMarkers.has(f.marker));
|
|
252
|
+
if (scoped.length > 0) {
|
|
253
|
+
lines.push('');
|
|
254
|
+
lines.push(renderFootnoteBlock(scoped));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
package/dist/index.d.ts
ADDED