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 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
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};