codexparser 0.3.1 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to this project are documented here. For full details, see the Release Notes in README and the GitHub Releases page.
4
4
 
5
+ ## 0.4.1 — 2026-04-28
6
+
7
+ ### Fixed
8
+
9
+ - **Genesis 5:32 LXX missing entry.** ENG/MT Gen 5:32 ("Noah was 500 years old, and he fathered Shem, Ham, and Japheth") is folded into LXX Göttingen Gen 6:1, so LXX Genesis 5 has only 31 verses. The previous data left ENG Gen 5:32 unmapped, so `convertVersion("lxx")` returned 32 verses for Gen 5 and downstream lookups against an LXX corpus 404'd. Now correctly emits `missingPassages: [{ verse: 32 }]` and `verses: ["1-31"]`.
10
+
11
+ ## 0.4.0 — 2026-04-28
12
+
13
+ LXX versification audit + structural fixes. Major data-correctness pass against authoritative sources (Hanhart's Göttingen Esther, Rahlfs-Hanhart 2006, Göttingen Theodotion Daniel/Susanna/Bel) verified via Logos library.
14
+
15
+ ### Added
16
+
17
+ - `parser.edition("rahlfs" | "auto")` — selects LXX edition. Default `"auto"` uses Göttingen where attested per `src/data/lxx-editions.js` and Rahlfs elsewhere.
18
+ - `passage.convertVersion(target, { edition })` — per-call edition override.
19
+ - `passage.getLXXRahlfs()` helper.
20
+ - `ReferenceParser.expandVersificationValue(value)` — parses `"ch:v"`, `"ch:v1-v2"`, `"ch:v[a-z]"`, returns empty array for malformed/empty input. Handles all shapes used in the data files without producing NaN.
21
+ - `verseSuffix` propagated through `scripture.cv`, `verses[]`, `to.verses[]`, `original`, and `abbr` so converting `Esther 11:2 ENG → LXX` outputs `Esther 1:1a` (was previously losing the letter suffix).
22
+ - `cloned.missingPassages` array on conversions when verses don't exist in the target version (e.g., 1 Kgs 4:21 LXX="").
23
+ - `src/data/lxx-editions.js` — registry of which OT books are in Göttingen vs. Rahlfs-only. Update as new Göttingen volumes ship.
24
+ - New versification files: `2kings.js` (ENG 11:21 = MT/LXX 12:1; ENG 12:1-21 = MT/LXX 12:2-22), `esther.js` (Hanhart-verified Vulgate ↔ Rahlfs letter-suffix mapping for Additions A-F).
25
+ - New `chapter_verses` extensions: Daniel 13 (Susanna, 64 v) + 14 (Bel, 42 v); Esther 10 extended through 16 for Vulgate/RSV-Apocrypha numbering of additions.
26
+ - `Song of Solomon` registered as an alias of `Song of Songs` in `versified.js`.
27
+
28
+ ### Fixed
29
+
30
+ - **MT-side versification bugs (116 entries):** entries that incorrectly left `mt = eng` when MT actually shifts. Verified by cross-checking against BHS in MongoDB.
31
+ - `Genesis 31:55-32:32` (33 entries): ENG 31:55 = MT 32:1, ENG 32:N = MT 32:N+1
32
+ - `1 Samuel 23:29` (1 entry): ENG 23:29 = MT 24:1
33
+ - `2 Samuel 18:33-19:43` (44 entries): ENG 18:33 = MT 19:1, ENG 19:N = MT 19:N+1
34
+ - `Psalms 92:0` (1 entry): ENG title = MT 92:1
35
+ - `Ezekiel 20:45-49` (5 entries): ENG 20:N = MT 21:N-44
36
+ - `Ezekiel 21:1-32` (32 entries): ENG 21:N = MT 21:N+5
37
+ - `numbers.js`: replaced corrupted final entry (`29:40 → lxx:"26:48"`); added Num 30:1-16 ENG = MT/LXX 30:2-17.
38
+ - `micah.js`: full rewrite. Was off by one on MT and unshifted on LXX. ENG 5:1 = MT/LXX 4:14, ENG 5:2-15 = MT/LXX 5:1-14.
39
+ - `psalms.js`: fixed Ps 147:10-20 boundary. Was at 9/10; correct boundary is at 11/12 (ENG 147:1-11 = LXX 146:1-11; ENG 147:12-20 = LXX 147:1-9).
40
+ - `genesis.js`: removed no-op `35:16` entry; corrected `35:21` to `lxx: ""` (verse missing in LXX).
41
+ - `expandVersificationValue` correctly handles range strings (e.g., Gen 31:48 LXX `"31:47-48"`) by emitting one sub-passage per verse.
42
+
43
+ ### Tests
44
+
45
+ - 77 assertion-based tests in `tests/lxx-versification-audit.test.js` covering `expandVersificationValue`, all the fixed entries, suffix preservation, edition switching, and the MT-side fixes. All passing.
46
+
5
47
  ## 0.3.0 — 2026-01-10
6
48
 
7
49
  - Added `convertVersion(targetVersion)` method on passage objects for versification conversion.
package/README.md CHANGED
@@ -10,10 +10,12 @@ Built with precision and passion, CodexParser handles single verses, ranges, mul
10
10
 
11
11
  ## Features 🌟
12
12
 
13
- - **Parse Any Reference**: From "Jn 3:16" to "Psalm 115:5,7,10", its got you covered.
13
+ - **Parse Any Reference**: From "Jn 3:16" to "Psalm 115:5,7,10", it's got you covered.
14
14
  - **Structured Output**: Get book, chapter, verses, testament, start/end points, SBL abbreviations, and versification data in a clean object.
15
15
  - **SBL Abbreviations**: Formatted references (e.g., "Ps. 115:5, 7, 10", "Gen. 1:1–3") with periods, en dashes for ranges, and commas with spaces for separated verses.
16
- - **Versification Support**: Handles differences between English, LXX, and MT texts, with mappings like Psalm 115 (LXX) to Psalm 116 (MT/ENG).
16
+ - **Versification Support**: Handles differences between English, LXX, and MT texts including the Greek additions to Esther (A–F) and Daniel (Susanna ch 13, Bel ch 14).
17
+ - **Göttingen vs. Rahlfs Editions**: Default to Göttingen where attested, fall back to Rahlfs elsewhere; switch with `parser.edition("rahlfs")`.
18
+ - **Letter-Suffixed Verses**: Hanhart's Esther additions (`1:1a`–`1:1s`, `4:17a`–`4:17z`, etc.) round-trip through `convertVersion` with `verseSuffix` carried through `scripture.cv`.
17
19
  - **Validation**: Checks if verses exist, with detailed error messages for invalid references.
18
20
  - **Combine Passages**: Merge multiple references into a single, cohesive range.
19
21
  - **Chainable API**: Fluent, intuitive method chaining for a smooth workflow.
@@ -157,6 +159,55 @@ Notes:
157
159
  - `getVersion("eng"|"lxx"|"mt"|"bhs")` is available; `getBHS()` aliases `MT`.
158
160
  - `.scripture.hash` is OSIS textual (e.g., `John.3.16`), `.osisNumeric` uses pythonbible-style integer IDs.
159
161
 
162
+ ### Editions: Göttingen vs. Rahlfs
163
+
164
+ CodexParser defaults to **Göttingen** versification where the critical edition exists and falls back to **Rahlfs** where it doesn't (per `src/data/lxx-editions.js`). Switch with `.edition()`:
165
+
166
+ ```javascript
167
+ const parser = new CodexParser()
168
+
169
+ // Default: "auto" (Göttingen-where-attested, Rahlfs-elsewhere)
170
+ const [a] = parser.parse("Genesis 31:55").getPassages()
171
+ console.log(a.getLXX().scripture.cv) // "32:1" (Wevers Genesis)
172
+
173
+ // Force Rahlfs across the whole parser
174
+ parser.edition("rahlfs")
175
+ const [b] = parser.parse("Genesis 31:55").getPassages()
176
+ console.log(b.getLXX().scripture.cv) // same here; would differ in books with `lxxRahlfs` overrides
177
+
178
+ // Per-call override (no need to switch the whole parser)
179
+ const [c] = parser.parse("Esther 1:1").getPassages()
180
+ console.log(c.convertVersion("lxx", { edition: "rahlfs" }).scripture.cv)
181
+ console.log(c.getLXXRahlfs().scripture.cv) // helper equivalent
182
+ ```
183
+
184
+ ### Greek additions: Esther A–F and Daniel 13/14
185
+
186
+ `Esther 11:2`–`16:24` (Vulgate / RSV-Apocrypha numbering) and `Daniel 13`/`14` (Susanna, Bel & the Dragon) parse and convert. Hanhart's letter-suffixed positions (`1:1a`, `4:17a`, `8:12x`, …) are carried through `verseSuffix`.
187
+
188
+ ```javascript
189
+ const parser = new CodexParser()
190
+
191
+ // Esther Addition A: ENG 11:2 -> LXX 1:1a
192
+ const [e] = parser.parse("Esther 11:2").getPassages()
193
+ const eLxx = e.convertVersion("lxx")
194
+ console.log(eLxx.scripture.cv) // "1:1a"
195
+ console.log(eLxx.passages[0].verseSuffix) // "a"
196
+ console.log(eLxx.passages[0].chapter, eLxx.passages[0].verse) // 1, 1
197
+
198
+ // Susanna (Daniel 13) parses canonically
199
+ const [s] = parser.parse("Daniel 13:1").getPassages()
200
+ console.log(s.valid) // true
201
+ const sMt = s.convertVersion("mt")
202
+ console.log(sMt.missingPassages?.length) // 1 (LXX-only chapter)
203
+
204
+ // Range / missing-in-target verses don't NaN-out
205
+ const [g] = parser.parse("Genesis 31:48").getPassages()
206
+ console.log(g.getLXX().scripture.cv) // "31:47-48" (range)
207
+ const [k] = parser.parse("1 Kings 4:21").getPassages()
208
+ console.log(k.getLXX().missingPassages[0].missingIn) // "lxx"
209
+ ```
210
+
160
211
  ---
161
212
 
162
213
  ## API: Your Codex Arsenal 🛠️
@@ -189,6 +240,24 @@ Here’s the breakdown of CodexParser’s key methods—your tools for mastering
189
240
  - **Returns**: The parser instance for chaining.
190
241
  - **Example**: `parser.bibleVersion("lxx").parse("Psalm 115:5,7,10");`
191
242
 
243
+ ### `.edition(edition)`
244
+
245
+ - **What it does**: Selects the LXX edition. `"auto"` (default) uses Göttingen where attested per `src/data/lxx-editions.js` and Rahlfs elsewhere. `"rahlfs"` forces Rahlfs versification universally and consults `lxxRahlfs` overrides where present.
246
+ - **Args**: `edition` (string) - `"auto"` or `"rahlfs"`.
247
+ - **Returns**: The parser instance for chaining.
248
+ - **Example**: `parser.edition("rahlfs").parse("Esther 11:2");`
249
+
250
+ ### `passage.convertVersion(target, options?)` / `passage.getLXXRahlfs()`
251
+
252
+ - **What it does**: Converts a parsed passage to another versification. `target` is `"eng"`, `"lxx"`, `"mt"`, or `"bhs"`. The optional `options.edition` (`"rahlfs"` or `"auto"`) overrides the parser-level edition for this single call. `getLXXRahlfs()` is a shortcut for `convertVersion("lxx", { edition: "rahlfs" })`.
253
+ - **Returns**: A cloned passage with `chapter` / `verse` / `verseSuffix` remapped, plus `cloned.missingPassages` listing any sub-passages that don't exist in the target version.
254
+ - **Example**:
255
+ ```javascript
256
+ const [p] = parser.parse("Esther 11:2").getPassages()
257
+ p.convertVersion("lxx").scripture.cv // "1:1a"
258
+ p.convertVersion("mt").missingPassages.length // 1 (Add A is LXX-only)
259
+ ```
260
+
192
261
  ### `.getPassages()`
193
262
 
194
263
  - **What it does**: Returns an array of parsed passage objects with handy methods like `.first()`, `.oldTestament()`, `.newTestament()`, and `.combine()`.
@@ -304,6 +373,29 @@ Let’s parse the scriptures together—happy coding! ✝️📚
304
373
 
305
374
  ## Release Notes
306
375
 
376
+ ### 0.4.0 (2026-04-28)
377
+
378
+ LXX versification audit + structural fixes. Major data-correctness pass against authoritative sources (Hanhart's Göttingen Esther, Rahlfs-Hanhart 2006, Göttingen Theodotion Susanna/Daniel/Bel) verified against a Logos library extract.
379
+
380
+ - New `parser.edition("rahlfs"|"auto")` setter and `passage.convertVersion(target, { edition })` per-call override; `passage.getLXXRahlfs()` helper.
381
+ - `ReferenceParser.expandVersificationValue()` parses `"ch:v"`, `"ch:v1-v2"`, `"ch:v[a-z]"`, and rejects empty/malformed input cleanly. The previously-broken cases (Gen 31:48 LXX `"31:47-48"`, 1 Kgs 4:21 LXX `""`, Isa 64:1 MT `"63:19b"`, Dan 3:24a) all parse correctly.
382
+ - `verseSuffix` is now carried through `scripture.cv`, `verses[]`, `original`, and `abbr` so `Esther 11:2 ENG → LXX` produces `Esther 1:1a` (not `1:1`).
383
+ - `cloned.missingPassages` is populated when a verse doesn't exist in the conversion target (e.g., 1 Kgs 4:21 LXX, Daniel 13 in MT).
384
+ - New `src/data/lxx-editions.js` registry; new `versifications/2kings.js`, `versifications/esther.js` (Hanhart-verified Vulgate ↔ Rahlfs Add A–F mapping); `chapter_verses` extended for Daniel 13/14 (Susanna + Bel) and Esther 10–16 (Vulgate apocrypha layout); `Song of Solomon` aliased to `Song of Songs`.
385
+ - **116 MT-side bugs fixed** across Genesis 31:55–32:32, 1 Samuel 23:29, 2 Samuel 18:33–19:43, Psalms 92:0, Ezekiel 20:45–21:32 — entries that incorrectly left `mt = eng` when MT actually shifts. Verified against BHS data.
386
+ - `numbers.js` corrupted final entry replaced + Num 30 added; `micah.js` rewritten; `psalms.js` Ps 147 boundary corrected from 9/10 to 11/12; `genesis.js` 35:16 no-op removed.
387
+ - 77 assertion-based tests in `tests/lxx-versification-audit.test.js`. All passing.
388
+ - Published to npm as `codexparser@0.4.0`.
389
+
390
+ ### 0.3.0 (2026-01-10)
391
+
392
+ - Added `convertVersion(targetVersion)` method on passage objects for versification conversion.
393
+ - Accepts version string: `"eng"`, `"lxx"`, `"mt"`, or `"bhs"` (alias for MT).
394
+ - Automatically converts chapter/verse references between versifications when versification data exists.
395
+ - Returns same reference with updated version metadata if no versification exists.
396
+ - Tested with Psalms, Zechariah, and NT passages.
397
+ - Published to npm as `codexparser@0.3.0`.
398
+
307
399
  ### 0.2.0 (2026-01-10)
308
400
 
309
401
  - Refactored internal architecture into clear folders:
@@ -0,0 +1,94 @@
1
+ # CodexParser 0.4.0
2
+
3
+ LXX versification audit + structural fixes. Major data-correctness pass against authoritative sources (Hanhart's Göttingen Esther 1983, Rahlfs-Hanhart 2006, Göttingen Theodotion Susanna/Daniel/Bel 1999) verified against a Logos library extract.
4
+
5
+ ## Highlights
6
+
7
+ - **Göttingen vs. Rahlfs editions are now selectable.** Default is "auto" (Göttingen where attested, Rahlfs elsewhere); call `parser.edition("rahlfs")` to force Rahlfs everywhere, or pass `{ edition: "rahlfs" }` to `convertVersion`.
8
+ - **Esther's Greek additions A–F now round-trip cleanly.** ENG `Esther 11:2` → LXX `Esther 1:1a` (with `verseSuffix: "a"` carried through `scripture.cv`, `verses[]`, `original`, and `abbr`).
9
+ - **Daniel 13 (Susanna) and 14 (Bel & the Dragon) added** to `chapter_verses` and `versifications/daniel.js` (Theodotion).
10
+ - **Letter-suffixed verses no longer produce NaN** in `convertVersion`. The previously-broken cases (Gen 31:48 LXX `"31:47-48"`, 1 Kgs 4:21 LXX `""`, Isa 64:1 MT `"63:19b"`, Dan 3:24a) all parse correctly via the new `ReferenceParser.expandVersificationValue()` helper.
11
+ - **116 MT-side versification bugs fixed** across Genesis, 1 Samuel, 2 Samuel, Psalms, and Ezekiel — entries that incorrectly left `mt = eng` when MT actually shifts. Cross-verified against BHS data.
12
+ - **Ps 147 boundary fixed** at 11/12 (was 9/10): ENG 147:1-11 = LXX 146:1-11; ENG 147:12-20 = LXX 147:1-9.
13
+ - **Micah versification rewritten** (was off by one on MT and unshifted on LXX): ENG 5:1 = MT/LXX 4:14, ENG 5:2-15 = MT/LXX 5:1-14.
14
+
15
+ ## What's new in the API
16
+
17
+ ```javascript
18
+ const CodexParser = require("codexparser")
19
+
20
+ // 1. Edition selection
21
+ const parser = new CodexParser().edition("rahlfs") // force Rahlfs
22
+ const auto = new CodexParser() // auto (default)
23
+
24
+ // 2. Per-call edition override
25
+ const [p] = parser.parse("Genesis 31:55").getPassages()
26
+ const lxxR = p.convertVersion("lxx", { edition: "rahlfs" })
27
+ const lxxR2 = p.getLXXRahlfs() // helper
28
+
29
+ // 3. Esther additions with letter suffix
30
+ const [e] = parser.parse("Esther 11:2").getPassages() // Add A
31
+ const lxx = e.convertVersion("lxx")
32
+ console.log(lxx.scripture.cv) // "1:1a"
33
+ console.log(lxx.passages[0].verseSuffix) // "a"
34
+
35
+ // 4. Daniel 13 (Susanna) parses canonically
36
+ const [s] = parser.parse("Daniel 13:1").getPassages() // valid: true
37
+ const mt = s.convertVersion("mt") // missingPassages: chapter LXX-only
38
+
39
+ // 5. Range/letter values handled cleanly
40
+ const [g] = parser.parse("Genesis 31:48").getPassages()
41
+ const gLxx = g.convertVersion("lxx")
42
+ console.log(gLxx.scripture.cv) // "31:47-48"
43
+ ```
44
+
45
+ ## What got fixed in the data
46
+
47
+ ### MT-side bugs (116 entries)
48
+
49
+ Each of these entries previously had `mt: "<ENG-key>"` (treating MT = ENG) when MT actually has an offset. Verified by checking against BHS texts.
50
+
51
+ | Book | Range | Pattern |
52
+ |------|-------|---------|
53
+ | Genesis | 31:55-32:32 | ENG 31:55 = MT 32:1; ENG 32:N = MT 32:N+1 |
54
+ | 1 Samuel | 23:29 | ENG 23:29 = MT 24:1 |
55
+ | 2 Samuel | 18:33-19:43 | ENG 18:33 = MT 19:1; ENG 19:N = MT 19:N+1 |
56
+ | Psalms | 92:0 | ENG title = MT 92:1 |
57
+ | Ezekiel | 20:45-49 | ENG 20:N = MT 21:N-44 |
58
+ | Ezekiel | 21:1-32 | ENG 21:N = MT 21:N+5 |
59
+
60
+ ### Other corrections
61
+
62
+ - `numbers.js`: replaced corrupted final entry; added Num 30:1-16 ENG = MT/LXX 30:2-17.
63
+ - `micah.js`: full rewrite (every entry was wrong).
64
+ - `psalms.js`: fixed Ps 147 boundary 9/10 → 11/12.
65
+ - `genesis.js`: removed no-op `35:16` entry; corrected `35:21` to `lxx: ""` (verse missing in LXX).
66
+
67
+ ## New data files
68
+
69
+ - `src/data/lxx-editions.js` — registry mapping each OT book to its current Göttingen attestation (`"gottingen"` or `"rahlfs"`). Update as new Göttingen volumes ship.
70
+ - `src/data/versifications/2kings.js` — ENG 11:21 = MT/LXX 12:1; ENG 12:1-21 = MT/LXX 12:2-22.
71
+ - `src/data/versifications/esther.js` — Hanhart-verified Vulgate ↔ Rahlfs letter-suffix mapping for Additions A–F.
72
+ - `src/data/chapter_verses/daniel.js` extended with Susanna (ch 13, 64 v) and Bel & Dragon (ch 14, 42 v).
73
+ - `src/data/chapter_verses/esther.js` extended through chapter 16 (Vulgate / RSV-Apocrypha layout).
74
+ - `Song of Solomon` registered as an alias of `Song of Songs` in `versified.js`.
75
+
76
+ ## New return-shape fields
77
+
78
+ - **`passage.passages[i].verseSuffix`** — letter suffix when present (`"a"`, `"s"`, `"ee"`, etc.).
79
+ - **`passage.edition`** — `"auto"` or `"rahlfs"`, the resolved edition for this passage.
80
+ - **`cloned.missingPassages`** — array of sub-passages that don't exist in the conversion target (e.g., 1 Kgs 4:21 LXX, Daniel 13 in MT).
81
+
82
+ ## Tests
83
+
84
+ 77 assertion-based tests in `tests/lxx-versification-audit.test.js` covering `expandVersificationValue`, every fixed entry, suffix preservation, edition switching, and the MT-side fixes. All passing. Run with:
85
+
86
+ ```bash
87
+ node tests/lxx-versification-audit.test.js
88
+ ```
89
+
90
+ ## Breaking changes
91
+
92
+ - `convertVersion` now returns `cloned.missingPassages` for verses that don't exist in the target version, where previously those would surface as NaN-valued `chapter`/`verse`. Callers that relied on NaN will need to handle the new `missingPassages` array instead.
93
+
94
+ See full CHANGELOG: https://github.com/jeremyam/CodexParser/blob/main/CHANGELOG.md
package/package.json CHANGED
@@ -1,8 +1,18 @@
1
1
  {
2
2
  "name": "codexparser",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "This is a Javascript Bible parser and text scanner. It will search through texts and collate all scripture references into an array and parse them into objects, and it will parse passages into objects by book, chapter, verse, and testament. ",
5
5
  "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "src/index.js",
9
+ "src/core/",
10
+ "src/data/",
11
+ "src/format/",
12
+ "src/utils/",
13
+ "CHANGELOG.md",
14
+ "RELEASE_NOTES_v0.4.0.md"
15
+ ],
6
16
  "scripts": {
7
17
  "test": "echo \"Error: no automated test runner configured\" && exit 0"
8
18
  },
@@ -39,6 +39,7 @@ class CodexParser {
39
39
  booksOnly: config.booksOnly ?? false,
40
40
  invalid_sequence_strategy: config.invalid_sequence_strategy ?? "include",
41
41
  invalid_passage_strategy: config.invalid_passage_strategy ?? "include",
42
+ edition: CodexParser.#normalizeEdition(config.edition),
42
43
  }
43
44
 
44
45
  this.#scanner = new ScriptureScanner(this.#config)
@@ -134,6 +135,7 @@ class CodexParser {
134
135
  booksOnly: config.booksOnly ?? this.#config.booksOnly,
135
136
  invalid_sequence_strategy: config.invalid_sequence_strategy ?? this.#config.invalid_sequence_strategy,
136
137
  invalid_passage_strategy: config.invalid_passage_strategy ?? this.#config.invalid_passage_strategy,
138
+ edition: config.edition !== undefined ? CodexParser.#normalizeEdition(config.edition) : this.#config.edition,
137
139
  }
138
140
 
139
141
  // Update scanner and parser configs
@@ -143,6 +145,25 @@ class CodexParser {
143
145
  return this
144
146
  }
145
147
 
148
+ /**
149
+ * Sets the LXX edition preference. "auto" (default) uses Göttingen where
150
+ * attested per src/data/lxx-editions.js and falls back to Rahlfs elsewhere.
151
+ * "rahlfs" forces Rahlfs versification universally.
152
+ * @param {string} edition - "auto" | "rahlfs"
153
+ * @returns {CodexParser}
154
+ */
155
+ edition(edition) {
156
+ this.#config.edition = CodexParser.#normalizeEdition(edition)
157
+ this.#parser = new ReferenceParser(this.#config)
158
+ return this
159
+ }
160
+
161
+ static #normalizeEdition(value) {
162
+ if (value == null) return "auto"
163
+ const v = String(value).toLowerCase()
164
+ return v === "rahlfs" ? "rahlfs" : "auto"
165
+ }
166
+
146
167
  /**
147
168
  * Retrieves available verses for a given book and chapter (legacy method)
148
169
  * @param {string} book - The book name
@@ -31,6 +31,39 @@ class ReferenceParser {
31
31
  this.#config = config
32
32
  }
33
33
 
34
+ /**
35
+ * Parses a versification value string into one or more {chapter, verse, suffix?} entries.
36
+ * Accepts:
37
+ * - "ch:v" → [{chapter, verse}]
38
+ * - "ch:v1-v2" → expanded to one entry per verse in range
39
+ * - "ch:v[a-z]" → [{chapter, verse, suffix}]
40
+ * Returns [] for unparseable input.
41
+ */
42
+ static expandVersificationValue(value) {
43
+ if (typeof value !== "string" || !value.includes(":")) return []
44
+ const [chPart, vPart] = value.split(":")
45
+ const chapter = Number(chPart)
46
+ if (!Number.isFinite(chapter)) return []
47
+
48
+ // Range form "v1-v2" (no letter suffixes inside ranges)
49
+ if (vPart.includes("-")) {
50
+ const [a, b] = vPart.split("-").map((s) => Number(s.trim()))
51
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b < a) return []
52
+ const out = []
53
+ for (let v = a; v <= b; v++) out.push({ chapter, verse: v })
54
+ return out
55
+ }
56
+
57
+ // Letter-suffix form "19b"
58
+ const match = vPart.match(/^(\d+)([a-zA-Z]+)?$/)
59
+ if (!match) return []
60
+ const verse = Number(match[1])
61
+ if (!Number.isFinite(verse)) return []
62
+ const entry = { chapter, verse }
63
+ if (match[2]) entry.suffix = match[2]
64
+ return [entry]
65
+ }
66
+
34
67
  /**
35
68
  * Parses found references into structured passage objects
36
69
  * @param {Array} foundReferences - Array of found references from scanner
@@ -53,6 +86,7 @@ class ReferenceParser {
53
86
  endIndex: reference.endIndex,
54
87
  originalText: reference.originalText,
55
88
  version: VersionHandler.getVersion(reference.version || currentVersion, testament),
89
+ edition: this.#config.edition || "auto",
56
90
  passages: [],
57
91
  scripture: null,
58
92
  valid: true,
@@ -128,19 +162,65 @@ class ReferenceParser {
128
162
  */
129
163
  #attachVersionHelpers(passage) {
130
164
  const self = this
131
- const computeConverted = (srcPassage, targetAbbr) => {
165
+ const computeConverted = (srcPassage, targetAbbr, options = {}) => {
132
166
  const versionObj = VersionHandler.getVersionObject(targetAbbr)
133
167
  const cloned = JSON.parse(JSON.stringify(srcPassage))
134
168
  cloned.version = versionObj
135
169
 
136
- // Remap chapters/verses according to versification, if present
170
+ // Resolve which LXX edition to consult. "rahlfs" prefers
171
+ // versification.lxxRahlfs when present; "auto" (default) uses the
172
+ // canonical lxx field, which by convention holds Göttingen where
173
+ // attested and Rahlfs elsewhere (see src/data/lxx-editions.js).
174
+ const edition =
175
+ options.edition != null
176
+ ? String(options.edition).toLowerCase()
177
+ : srcPassage.edition || "auto"
178
+
179
+ const resolveTargetKey = (versification) => {
180
+ if (targetAbbr === "lxx" && edition === "rahlfs" && versification.lxxRahlfs !== undefined) {
181
+ return "lxxRahlfs"
182
+ }
183
+ return targetAbbr
184
+ }
185
+
186
+ // Remap chapters/verses according to versification, if present.
187
+ // Versification values may be strict "ch:v", a range "ch:v1-v2",
188
+ // a letter-suffixed verse "ch:v[a-z]", or "" / null when the verse
189
+ // does not exist in the target version. We expand ranges and split
190
+ // missing verses into cloned.missingPassages so summary fields
191
+ // never contain NaN.
192
+ const remapped = []
193
+ const missing = []
137
194
  cloned.passages.forEach((sub) => {
138
- if (sub.versification && sub.versification[targetAbbr]) {
139
- const [ch, v] = sub.versification[targetAbbr].split(":").map(Number)
140
- sub.chapter = ch
141
- sub.verse = v
195
+ if (!sub.versification) {
196
+ remapped.push(sub)
197
+ return
142
198
  }
199
+ const key = resolveTargetKey(sub.versification)
200
+ if (!(key in sub.versification)) {
201
+ remapped.push(sub)
202
+ return
203
+ }
204
+ const target = sub.versification[key]
205
+ if (target == null || target === "") {
206
+ missing.push({ ...sub, missingIn: targetAbbr })
207
+ return
208
+ }
209
+ const expanded = ReferenceParser.expandVersificationValue(target)
210
+ if (expanded.length === 0) {
211
+ remapped.push(sub)
212
+ return
213
+ }
214
+ expanded.forEach((entry, idx) => {
215
+ const next = idx === 0 ? sub : JSON.parse(JSON.stringify(sub))
216
+ next.chapter = entry.chapter
217
+ next.verse = entry.verse
218
+ if (entry.suffix) next.verseSuffix = entry.suffix
219
+ remapped.push(next)
220
+ })
143
221
  })
222
+ cloned.passages = remapped
223
+ if (missing.length > 0) cloned.missingPassages = missing
144
224
 
145
225
  // Sort and recompute summary fields
146
226
  cloned.passages.sort((a, b) => a.chapter - b.chapter || a.verse - b.verse)
@@ -158,13 +238,13 @@ class ReferenceParser {
158
238
  }
159
239
  }
160
240
 
161
- const chapterVersesMap = {}
241
+ const chapterEntries = {}
162
242
  cloned.passages.forEach((p) => {
163
- if (!chapterVersesMap[p.chapter]) chapterVersesMap[p.chapter] = new Set()
164
- chapterVersesMap[p.chapter].add(p.verse)
243
+ if (!chapterEntries[p.chapter]) chapterEntries[p.chapter] = []
244
+ chapterEntries[p.chapter].push({ verse: p.verse, suffix: p.verseSuffix || "" })
165
245
  })
166
246
 
167
- const sortedChs = Object.keys(chapterVersesMap)
247
+ const sortedChs = Object.keys(chapterEntries)
168
248
  .map(Number)
169
249
  .sort((a, b) => a - b)
170
250
  const chapterStrs = []
@@ -187,13 +267,32 @@ class ReferenceParser {
187
267
  return merged
188
268
  }
189
269
 
270
+ const formatChapterVerses = (entries) => {
271
+ const usable = entries.filter((e) => e.verse > 0)
272
+ if (usable.length === 0) return []
273
+ const sorted = [...usable].sort(
274
+ (a, b) => a.verse - b.verse || a.suffix.localeCompare(b.suffix)
275
+ )
276
+ if (sorted.some((e) => e.suffix)) {
277
+ // Letter-suffixed verses (Esther additions, Isa 63:19b, Dan 3:24a):
278
+ // do not range-merge - emit each as "<verse><suffix>" individually.
279
+ const seen = new Set()
280
+ const out = []
281
+ for (const e of sorted) {
282
+ const tag = `${e.verse}${e.suffix}`
283
+ if (seen.has(tag)) continue
284
+ seen.add(tag)
285
+ out.push(tag)
286
+ }
287
+ return out
288
+ }
289
+ return mergeRanges([...new Set(sorted.map((e) => e.verse))])
290
+ }
291
+
190
292
  sortedChs.forEach((ch) => {
191
- const vs = Array.from(chapterVersesMap[ch])
192
- .filter((v) => v > 0)
193
- .sort((a, b) => a - b)
194
- if (vs.length > 0) {
195
- const merged = mergeRanges(vs)
196
- chapterStrs.push(`${ch}:${merged.join(",")}`)
293
+ const formatted = formatChapterVerses(chapterEntries[ch])
294
+ if (formatted.length > 0) {
295
+ chapterStrs.push(`${ch}:${formatted.join(",")}`)
197
296
  }
198
297
  })
199
298
 
@@ -205,20 +304,21 @@ class ReferenceParser {
205
304
  const lastCh = sortedChs[sortedChs.length - 1]
206
305
  cloned.chapter = firstCh
207
306
 
208
- const mergedFirst = mergeRanges(chapterVersesMap[firstCh] || new Set())
209
- cloned.verses = mergedFirst
307
+ const formattedFirst = formatChapterVerses(chapterEntries[firstCh] || [])
308
+ cloned.verses = formattedFirst
210
309
 
211
310
  if (firstCh !== lastCh) {
212
311
  cloned.type = ReferenceParser.REFERENCE_TYPES.MULTI_CHAPTER_RANGE
213
312
  cloned.to = {
214
313
  book: cloned.book,
215
314
  chapter: lastCh,
216
- verses: mergeRanges(chapterVersesMap[lastCh] || new Set()),
315
+ verses: formatChapterVerses(chapterEntries[lastCh] || []),
217
316
  }
218
317
  cloned.original = `${cloned.book} ${chapterStrs.join("; ")}`
219
318
  } else {
220
319
  const hasRangeOrMultiple =
221
- mergedFirst.length > 1 || (mergedFirst.length === 1 && mergedFirst[0].includes("-"))
320
+ formattedFirst.length > 1 ||
321
+ (formattedFirst.length === 1 && formattedFirst[0].includes("-"))
222
322
  cloned.type = hasRangeOrMultiple
223
323
  ? ReferenceParser.REFERENCE_TYPES.CHAPTER_VERSE_RANGE
224
324
  : ReferenceParser.REFERENCE_TYPES.CHAPTER_VERSE
@@ -251,12 +351,15 @@ class ReferenceParser {
251
351
  return cloned
252
352
  }
253
353
 
254
- passage.getVersion = function (targetVersion) {
354
+ passage.getVersion = function (targetVersion, options = {}) {
255
355
  const targetAbbr = targetVersion.toLowerCase() === "bhs" ? "mt" : targetVersion.toLowerCase()
256
- return computeConverted(this, targetAbbr)
356
+ return computeConverted(this, targetAbbr, options)
357
+ }
358
+ passage.getLXX = function (options = {}) {
359
+ return this.getVersion("lxx", options)
257
360
  }
258
- passage.getLXX = function () {
259
- return this.getVersion("lxx")
361
+ passage.getLXXRahlfs = function () {
362
+ return this.getVersion("lxx", { edition: "rahlfs" })
260
363
  }
261
364
  passage.getMT = function () {
262
365
  return this.getVersion("mt")
@@ -267,7 +370,7 @@ class ReferenceParser {
267
370
  passage.getEnglish = function () {
268
371
  return this.getVersion("eng")
269
372
  }
270
- passage.convertVersion = function (targetVersion) {
373
+ passage.convertVersion = function (targetVersion, options = {}) {
271
374
  const targetAbbr = targetVersion.toLowerCase() === "bhs" ? "mt" : targetVersion.toLowerCase()
272
375
 
273
376
  // Check if any passages have versification data
@@ -281,7 +384,7 @@ class ReferenceParser {
281
384
  }
282
385
 
283
386
  // Has versification, use the full conversion
284
- return computeConverted(this, targetAbbr)
387
+ return computeConverted(this, targetAbbr, options)
285
388
  }
286
389
  }
287
390
 
@@ -11,4 +11,8 @@ module.exports = {
11
11
  10: Array.from({ length: 21 }, (_, i) => i + 1), // Daniel 10 has 21 verses
12
12
  11: Array.from({ length: 45 }, (_, i) => i + 1), // Daniel 11 has 45 verses
13
13
  12: Array.from({ length: 13 }, (_, i) => i + 1), // Daniel 12 has 13 verses
14
+ // Greek (deuterocanonical) Daniel: Susanna and Bel & the Dragon, present
15
+ // in LXX (both Theodotion and Old Greek) and absent from MT.
16
+ 13: Array.from({ length: 64 }, (_, i) => i + 1), // Susanna (Theodotion)
17
+ 14: Array.from({ length: 42 }, (_, i) => i + 1), // Bel and the Dragon (Theodotion)
14
18
  }
@@ -1,3 +1,8 @@
1
+ // Esther chapter:verse counts using the Vulgate / RSV-with-Apocrypha layout,
2
+ // which appends the six Greek additions (A-F) as chapters 10:4-16:24.
3
+ // Hebrew Esther runs through 10:3; verses 10:4 onward are deuterocanonical
4
+ // (LXX-only). Note: this changes validation - "Esther 11:2" will now resolve
5
+ // even in eng mode. Canonical-only validation is a higher-level concern.
1
6
  module.exports = {
2
7
  1: Array.from({ length: 22 }, (_, i) => i + 1), // Esther 1 has 22 verses
3
8
  2: Array.from({ length: 23 }, (_, i) => i + 1), // Esther 2 has 23 verses
@@ -8,5 +13,11 @@ module.exports = {
8
13
  7: Array.from({ length: 10 }, (_, i) => i + 1), // Esther 7 has 10 verses
9
14
  8: Array.from({ length: 17 }, (_, i) => i + 1), // Esther 8 has 17 verses
10
15
  9: Array.from({ length: 32 }, (_, i) => i + 1), // Esther 9 has 32 verses
11
- 10: Array.from({ length: 3 }, (_, i) => i + 1), // Esther 10 has 3 verses
16
+ 10: Array.from({ length: 13 }, (_, i) => i + 1), // 10:1-3 Hebrew + 10:4-13 Addition F
17
+ 11: Array.from({ length: 12 }, (_, i) => i + 1), // 11:1 colophon + 11:2-12 Addition A pt 1
18
+ 12: Array.from({ length: 6 }, (_, i) => i + 1), // 12:1-6 Addition A pt 2
19
+ 13: Array.from({ length: 18 }, (_, i) => i + 1), // 13:1-7 Add B + 13:8-18 Add C (Mordecai's prayer)
20
+ 14: Array.from({ length: 19 }, (_, i) => i + 1), // 14:1-19 Add C (Esther's prayer)
21
+ 15: Array.from({ length: 16 }, (_, i) => i + 1), // 15:1-16 Add D (Esther approaches the king)
22
+ 16: Array.from({ length: 24 }, (_, i) => i + 1), // 16:1-24 Add E (king's second decree)
12
23
  }