@life-and-dev/mdsite 0.0.3

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.
Files changed (133) hide show
  1. package/README.md +228 -0
  2. package/bin/mdsite.js +3 -0
  3. package/dist/commands/commands.test.d.ts +1 -0
  4. package/dist/commands/commands.test.js +302 -0
  5. package/dist/commands/commands.test.js.map +1 -0
  6. package/dist/commands/generate.d.ts +1 -0
  7. package/dist/commands/generate.js +20 -0
  8. package/dist/commands/generate.js.map +1 -0
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +16 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/commands/prepare.d.ts +1 -0
  13. package/dist/commands/prepare.js +111 -0
  14. package/dist/commands/prepare.js.map +1 -0
  15. package/dist/commands/prepare.test.d.ts +1 -0
  16. package/dist/commands/prepare.test.js +131 -0
  17. package/dist/commands/prepare.test.js.map +1 -0
  18. package/dist/commands/preview.d.ts +5 -0
  19. package/dist/commands/preview.js +62 -0
  20. package/dist/commands/preview.js.map +1 -0
  21. package/dist/commands/start.d.ts +5 -0
  22. package/dist/commands/start.js +50 -0
  23. package/dist/commands/start.js.map +1 -0
  24. package/dist/commands/stop.d.ts +1 -0
  25. package/dist/commands/stop.js +26 -0
  26. package/dist/commands/stop.js.map +1 -0
  27. package/dist/commands/workflows.test.d.ts +1 -0
  28. package/dist/commands/workflows.test.js +183 -0
  29. package/dist/commands/workflows.test.js.map +1 -0
  30. package/dist/config/default-mdsite-config.d.ts +2 -0
  31. package/dist/config/default-mdsite-config.js +82 -0
  32. package/dist/config/default-mdsite-config.js.map +1 -0
  33. package/dist/config/default-mdsite-config.test.d.ts +1 -0
  34. package/dist/config/default-mdsite-config.test.js +42 -0
  35. package/dist/config/default-mdsite-config.test.js.map +1 -0
  36. package/dist/config/mdsite-config.d.ts +42 -0
  37. package/dist/config/mdsite-config.js +74 -0
  38. package/dist/config/mdsite-config.js.map +1 -0
  39. package/dist/config/mdsite-config.test.d.ts +1 -0
  40. package/dist/config/mdsite-config.test.js +115 -0
  41. package/dist/config/mdsite-config.test.js.map +1 -0
  42. package/dist/config/menu.d.ts +3 -0
  43. package/dist/config/menu.js +58 -0
  44. package/dist/config/menu.js.map +1 -0
  45. package/dist/config/menu.test.d.ts +1 -0
  46. package/dist/config/menu.test.js +53 -0
  47. package/dist/config/menu.test.js.map +1 -0
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +99 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.test.d.ts +1 -0
  52. package/dist/index.test.js +133 -0
  53. package/dist/index.test.js.map +1 -0
  54. package/dist/process/child-process.d.ts +9 -0
  55. package/dist/process/child-process.js +142 -0
  56. package/dist/process/child-process.js.map +1 -0
  57. package/dist/process/child-process.test.d.ts +1 -0
  58. package/dist/process/child-process.test.js +131 -0
  59. package/dist/process/child-process.test.js.map +1 -0
  60. package/dist/process/runtime-state.d.ts +16 -0
  61. package/dist/process/runtime-state.js +42 -0
  62. package/dist/process/runtime-state.js.map +1 -0
  63. package/dist/process/runtime-state.test.d.ts +1 -0
  64. package/dist/process/runtime-state.test.js +57 -0
  65. package/dist/process/runtime-state.test.js.map +1 -0
  66. package/dist/renderer/mdsite-nuxt.d.ts +22 -0
  67. package/dist/renderer/mdsite-nuxt.js +194 -0
  68. package/dist/renderer/mdsite-nuxt.js.map +1 -0
  69. package/dist/renderer/mdsite-nuxt.test.d.ts +1 -0
  70. package/dist/renderer/mdsite-nuxt.test.js +226 -0
  71. package/dist/renderer/mdsite-nuxt.test.js.map +1 -0
  72. package/mdsite-nuxt/app/app.vue +7 -0
  73. package/mdsite-nuxt/app/assets/css/bible-tooltips.css +87 -0
  74. package/mdsite-nuxt/app/assets/css/markdown.css +195 -0
  75. package/mdsite-nuxt/app/assets/css/print.css +133 -0
  76. package/mdsite-nuxt/app/components/AppBar.vue +138 -0
  77. package/mdsite-nuxt/app/components/AppFooter.vue +88 -0
  78. package/mdsite-nuxt/app/components/AppNavigation.vue +162 -0
  79. package/mdsite-nuxt/app/components/AppTableOfContents.vue +149 -0
  80. package/mdsite-nuxt/app/components/BreadcrumbNav.vue +139 -0
  81. package/mdsite-nuxt/app/components/NavigationTree.vue +169 -0
  82. package/mdsite-nuxt/app/components/SearchBox.vue +212 -0
  83. package/mdsite-nuxt/app/components/TocItem.vue +75 -0
  84. package/mdsite-nuxt/app/components/TreeNode.vue +264 -0
  85. package/mdsite-nuxt/app/components/content/MarkdownAlert.vue +42 -0
  86. package/mdsite-nuxt/app/components/content/Mermaid.vue +103 -0
  87. package/mdsite-nuxt/app/components/content/ProseA.vue +57 -0
  88. package/mdsite-nuxt/app/components/content/ProseBlockquote.vue +14 -0
  89. package/mdsite-nuxt/app/components/content/ProsePre.vue +49 -0
  90. package/mdsite-nuxt/app/components/content/ProseTable.vue +143 -0
  91. package/mdsite-nuxt/app/composables/useAppTheme.ts +89 -0
  92. package/mdsite-nuxt/app/composables/useBibleTooltips.ts +21 -0
  93. package/mdsite-nuxt/app/composables/useBreadcrumbs.ts +70 -0
  94. package/mdsite-nuxt/app/composables/useContentPostProcessing.ts +89 -0
  95. package/mdsite-nuxt/app/composables/useNavigationTree.ts +174 -0
  96. package/mdsite-nuxt/app/composables/useSearchIndex.ts +47 -0
  97. package/mdsite-nuxt/app/composables/useSearchRelevance.test.ts +255 -0
  98. package/mdsite-nuxt/app/composables/useSearchRelevance.ts +207 -0
  99. package/mdsite-nuxt/app/composables/useSidebarState.ts +62 -0
  100. package/mdsite-nuxt/app/composables/useSiteConfig.ts +37 -0
  101. package/mdsite-nuxt/app/composables/useSourceEdit.ts +69 -0
  102. package/mdsite-nuxt/app/composables/useTableOfContents.ts +141 -0
  103. package/mdsite-nuxt/app/composables/useTableParser.ts +106 -0
  104. package/mdsite-nuxt/app/composables/useTooltipConfig.ts +20 -0
  105. package/mdsite-nuxt/app/config/themes.ts +17 -0
  106. package/mdsite-nuxt/app/layouts/default.vue +376 -0
  107. package/mdsite-nuxt/app/pages/[...slug].vue +56 -0
  108. package/mdsite-nuxt/app/pages/index.vue +54 -0
  109. package/mdsite-nuxt/app/plugins/bible-tooltips.client.ts +401 -0
  110. package/mdsite-nuxt/app/plugins/bible-tooltips.test.ts +284 -0
  111. package/mdsite-nuxt/app/types/content.d.ts +10 -0
  112. package/mdsite-nuxt/app/types/table.ts +57 -0
  113. package/mdsite-nuxt/app/utils/bible-book-names.ts +37 -0
  114. package/mdsite-nuxt/app/utils/bible-verse-utils.test.ts +148 -0
  115. package/mdsite-nuxt/app/utils/bible-verse-utils.ts +236 -0
  116. package/mdsite-nuxt/content.config.ts +18 -0
  117. package/mdsite-nuxt/example.config.yml +67 -0
  118. package/mdsite-nuxt/nuxt.config.ts +247 -0
  119. package/mdsite-nuxt/package-lock.json +15922 -0
  120. package/mdsite-nuxt/package.json +50 -0
  121. package/mdsite-nuxt/playwright.config.js +47 -0
  122. package/mdsite-nuxt/public/robots.txt +2 -0
  123. package/mdsite-nuxt/scripts/generate-favicons.ts +192 -0
  124. package/mdsite-nuxt/scripts/generate-indices.ts +508 -0
  125. package/mdsite-nuxt/scripts/renderer-hooks.test.ts +277 -0
  126. package/mdsite-nuxt/scripts/renderer-hooks.ts +141 -0
  127. package/mdsite-nuxt/scripts/start.test.ts +164 -0
  128. package/mdsite-nuxt/scripts/start.ts +55 -0
  129. package/mdsite-nuxt/scripts/sync-content.ts +504 -0
  130. package/mdsite-nuxt/tsconfig.json +18 -0
  131. package/mdsite-nuxt/utils/mdsite-config.ts +240 -0
  132. package/mdsite-nuxt/vitest.config.ts +7 -0
  133. package/package.json +36 -0
@@ -0,0 +1,401 @@
1
+ // Custom Bible Verse Tooltip Plugin using Bolls.life API
2
+ import {
3
+ processBollsVerse,
4
+ processBollsVerseRange,
5
+ createBibleHubInterlinearUrl,
6
+ parseReference,
7
+ getBookNumber,
8
+ type ProcessedBibleVerse
9
+ } from '~/utils/bible-verse-utils'
10
+
11
+ export default defineNuxtPlugin((nuxtApp) => {
12
+ if (process.server) return
13
+
14
+ class BibleTooltips {
15
+ private cache = new Map<string, ProcessedBibleVerse>()
16
+ private tooltip: HTMLElement | null = null
17
+ private overlay: HTMLElement | null = null
18
+ private currentLockState = false
19
+ private currentCloseTimeout: NodeJS.Timeout | null = null
20
+ private onHideCallback: (() => void) | null = null
21
+ private currentElement: HTMLElement | null = null // Track which element currently owns the tooltip
22
+
23
+ constructor() {
24
+ // Create overlay immediately on construction
25
+ this.overlay = this.createOverlay()
26
+ this.setupOverlayHandlers()
27
+ this.initializeTooltips()
28
+ }
29
+
30
+ private setupOverlayHandlers() {
31
+ if (!this.overlay) return
32
+
33
+ // Add touchstart handler in CAPTURE phase to catch events early
34
+ this.overlay.addEventListener('touchstart', (e) => {
35
+ e.preventDefault()
36
+ e.stopPropagation()
37
+ this.hideTooltip()
38
+ }, { capture: true })
39
+
40
+ // Add touchend handler in CAPTURE phase
41
+ this.overlay.addEventListener('touchend', (e) => {
42
+ e.preventDefault()
43
+ e.stopPropagation()
44
+ }, { capture: true })
45
+
46
+ // Add click handler for desktop
47
+ this.overlay.addEventListener('click', (e) => {
48
+ e.stopPropagation()
49
+ this.hideTooltip()
50
+ }, { capture: true })
51
+ }
52
+
53
+ private async fetchVerse(fullReference: string): Promise<ProcessedBibleVerse> {
54
+ if (this.cache.has(fullReference)) {
55
+ return this.cache.get(fullReference)!
56
+ }
57
+
58
+ // Parse reference to extract translation (defaults to ESV)
59
+ const { reference, translation } = parseReference(fullReference)
60
+
61
+ try {
62
+ // Parse the reference: "John 3:16" or "John 3:16-18"
63
+ const match = reference.match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?/)
64
+
65
+ if (!match) {
66
+ throw new Error(`Invalid reference format: ${reference}`)
67
+ }
68
+
69
+ const [, bookName, chapter, startVerse, endVerse] = match
70
+
71
+ // Type guards: ensure captured groups exist
72
+ if (!bookName || !chapter || !startVerse) {
73
+ throw new Error(`Invalid reference format: ${reference}`)
74
+ }
75
+
76
+ // Get book number (1-66)
77
+ const bookNumber = getBookNumber(bookName)
78
+ if (bookNumber === null) {
79
+ throw new Error(`Unknown book: ${bookName}`)
80
+ }
81
+
82
+ let result: ProcessedBibleVerse
83
+
84
+ if (endVerse) {
85
+ // Verse range: fetch entire chapter and filter
86
+ // Format: https://bolls.life/get-text/ESV/43/3/
87
+ const url = `https://bolls.life/get-text/${translation}/${bookNumber}/${chapter}/`
88
+ const response = await fetch(url)
89
+
90
+ if (!response.ok) {
91
+ throw new Error(`API request failed with status ${response.status}`)
92
+ }
93
+
94
+ const data = await response.json()
95
+ result = processBollsVerseRange(
96
+ data,
97
+ translation,
98
+ parseInt(startVerse),
99
+ parseInt(endVerse)
100
+ )
101
+ } else {
102
+ // Single verse
103
+ // Format: https://bolls.life/get-verse/ESV/43/3/16/
104
+ const url = `https://bolls.life/get-verse/${translation}/${bookNumber}/${chapter}/${startVerse}/`
105
+ const response = await fetch(url)
106
+
107
+ if (!response.ok) {
108
+ throw new Error(`API request failed with status ${response.status}`)
109
+ }
110
+
111
+ const data = await response.json()
112
+ result = processBollsVerse(data, translation)
113
+ }
114
+
115
+ if (!result.text) {
116
+ return {
117
+ text: 'Click the links below to read this verse:',
118
+ translation: translation
119
+ }
120
+ }
121
+
122
+ this.cache.set(fullReference, result)
123
+ return result
124
+ } catch (error) {
125
+ console.warn('Bible API unavailable:', error)
126
+ // Return friendly message with working links
127
+ return {
128
+ text: 'Click the links below to read this verse:',
129
+ translation: translation
130
+ }
131
+ }
132
+ }
133
+
134
+ private createOverlay(): HTMLElement {
135
+ const overlay = document.createElement('div')
136
+ overlay.className = 'bible-tooltip-overlay'
137
+ document.body.appendChild(overlay)
138
+ return overlay
139
+ }
140
+
141
+ private createTooltip(): HTMLElement {
142
+ const tooltip = document.createElement('div')
143
+ tooltip.className = 'bible-tooltip'
144
+ document.body.appendChild(tooltip)
145
+ return tooltip
146
+ }
147
+
148
+ private showTooltip(element: HTMLElement, verseData: ProcessedBibleVerse, event: MouseEvent) {
149
+ if (!this.tooltip) {
150
+ this.tooltip = this.createTooltip()
151
+
152
+ // Prevent tooltip from closing when mouse is over it
153
+ this.tooltip.addEventListener('mouseenter', () => {
154
+ // Cancel any pending hide
155
+ if (this.currentCloseTimeout) {
156
+ clearTimeout(this.currentCloseTimeout)
157
+ this.currentCloseTimeout = null
158
+ }
159
+ })
160
+
161
+ this.tooltip.addEventListener('mouseleave', () => {
162
+ // Only hide if not locked, with small delay to allow moving to next verse
163
+ if (!this.currentLockState) {
164
+ this.currentCloseTimeout = setTimeout(() => {
165
+ if (!this.currentLockState) {
166
+ this.hideTooltip()
167
+ }
168
+ }, 100)
169
+ }
170
+ })
171
+ }
172
+
173
+ // Get full reference from data-reference attribute (handles shorthand expansion)
174
+ const reference = (element as HTMLElement).getAttribute('data-reference') || element.textContent || ''
175
+ const bibleGatewayUrl = `https://www.biblegateway.com/passage/?search=${encodeURIComponent(reference)}&version=ESV`
176
+ const bibleHubUrl = createBibleHubInterlinearUrl(reference)
177
+
178
+ // Build title with translation
179
+ const title = verseData.translation
180
+ ? `${reference} <span class="bible-tooltip-translation">(${verseData.translation})</span>`
181
+ : reference
182
+
183
+ this.tooltip.innerHTML = `
184
+ <div class="bible-tooltip-title">${title}</div>
185
+ <div class="bible-tooltip-text">${verseData.text}</div>
186
+ <div class="bible-tooltip-footer">
187
+ <a href="${bibleGatewayUrl}" target="_blank" rel="noopener noreferrer" class="bible-tooltip-link"><i class="mdi mdi-book-open-variant"></i><span class="bible-tooltip-link-text">Full Context</span></a>
188
+ <a href="${bibleHubUrl}" target="_blank" rel="noopener noreferrer" class="bible-tooltip-link"><span class="bible-tooltip-hebrew-greek">אΩ</span><span class="bible-tooltip-link-text">Interlinear</span></a>
189
+ </div>
190
+ `
191
+
192
+ this.tooltip.style.display = 'block'
193
+
194
+ // Position tooltip
195
+ const rect = element.getBoundingClientRect()
196
+ let left = event.clientX
197
+ let top = rect.top - this.tooltip.offsetHeight - 10
198
+
199
+ // Adjust if tooltip goes off screen
200
+ if (left + this.tooltip.offsetWidth > window.innerWidth) {
201
+ left = window.innerWidth - this.tooltip.offsetWidth - 10
202
+ }
203
+ if (top < 10) {
204
+ top = rect.bottom + 10
205
+ }
206
+
207
+ this.tooltip.style.left = left + 'px'
208
+ this.tooltip.style.top = top + 'px'
209
+ }
210
+
211
+ private hideTooltip() {
212
+ if (this.tooltip) {
213
+ this.tooltip.style.display = 'none'
214
+ }
215
+ if (this.overlay) {
216
+ this.overlay.style.display = 'none'
217
+ }
218
+ this.currentLockState = false
219
+ this.currentElement = null // Clear current element owner
220
+ if (this.currentCloseTimeout) {
221
+ clearTimeout(this.currentCloseTimeout)
222
+ this.currentCloseTimeout = null
223
+ }
224
+
225
+ // Notify element that tooltip is hidden
226
+ if (this.onHideCallback) {
227
+ this.onHideCallback()
228
+ this.onHideCallback = null
229
+ }
230
+ }
231
+
232
+ private async handleMouseEnter(element: HTMLElement, reference: string, event: MouseEvent) {
233
+ // Show loading tooltip immediately
234
+ this.showTooltip(element, { text: 'Loading...', translation: '' }, event)
235
+
236
+ // Fetch verse
237
+ const verseData = await this.fetchVerse(reference)
238
+
239
+ // Only show if tooltip is still visible (not dismissed while fetching)
240
+ if (this.tooltip && this.tooltip.style.display === 'block') {
241
+ this.showTooltip(element, verseData, event)
242
+ }
243
+ }
244
+
245
+
246
+ public scan(container?: HTMLElement) {
247
+ // Bible verses are now wrapped in spans at build time (via content transformer)
248
+ // We just need to attach event listeners to existing .bible-ref elements
249
+ this.attachEventListeners(container)
250
+ }
251
+
252
+ /**
253
+ * Attach event listeners to pre-existing .bible-ref spans
254
+ * (Spans are created at build time by the content transformer)
255
+ */
256
+ private attachEventListeners(container?: HTMLElement) {
257
+ const root = container || document.body
258
+
259
+ // Find all .bible-ref elements that don't have event listeners yet
260
+ root.querySelectorAll('.bible-ref:not([data-bible-processed])').forEach(element => {
261
+ element.setAttribute('data-bible-processed', 'true')
262
+
263
+ const reference = element.getAttribute('data-reference')
264
+ if (!reference) return
265
+
266
+ let isTooltipVisible = false
267
+
268
+ // Mouse events for desktop
269
+ element.addEventListener('mouseenter', (e) => {
270
+ if (!isTooltipVisible) {
271
+ // If another element currently owns the tooltip, reset its state
272
+ if (this.currentElement && this.currentElement !== element && this.onHideCallback) {
273
+ this.onHideCallback()
274
+ this.onHideCallback = null
275
+ }
276
+
277
+ // Clear any pending close timeout from OTHER verses
278
+ if (this.currentCloseTimeout) {
279
+ clearTimeout(this.currentCloseTimeout)
280
+ this.currentCloseTimeout = null
281
+ }
282
+
283
+ this.handleMouseEnter(element as HTMLElement, reference, e as MouseEvent)
284
+ isTooltipVisible = true
285
+ this.currentElement = element as HTMLElement // Track this element as owner
286
+
287
+ // Register callback to reset visible state when hidden
288
+ this.onHideCallback = () => {
289
+ isTooltipVisible = false
290
+ }
291
+ }
292
+ })
293
+
294
+ element.addEventListener('mouseleave', () => {
295
+ // Only auto-close if THIS element owns the tooltip and not locked
296
+ if (!this.currentLockState && this.currentElement === element) {
297
+ this.currentCloseTimeout = setTimeout(() => {
298
+ // Double-check this element still owns the tooltip
299
+ if (!this.currentLockState && this.currentElement === element) {
300
+ this.hideTooltip()
301
+ isTooltipVisible = false
302
+ }
303
+ }, 300)
304
+ }
305
+ })
306
+
307
+ // Click event to lock tooltip
308
+ element.addEventListener('click', (e) => {
309
+ e.preventDefault()
310
+ e.stopPropagation()
311
+
312
+ // Clear any pending close timeout
313
+ if (this.currentCloseTimeout) {
314
+ clearTimeout(this.currentCloseTimeout)
315
+ this.currentCloseTimeout = null
316
+ }
317
+
318
+ // Lock the tooltip and show overlay
319
+ this.currentLockState = true
320
+ if (this.overlay) {
321
+ this.overlay.style.display = 'block'
322
+ }
323
+
324
+ if (!isTooltipVisible) {
325
+ this.handleMouseEnter(element as HTMLElement, reference, e as MouseEvent)
326
+ isTooltipVisible = true
327
+ }
328
+ })
329
+
330
+ // Touch events for mobile
331
+ element.addEventListener('touchstart', (e: Event) => {
332
+ const touchEvent = e as TouchEvent
333
+ touchEvent.preventDefault() // Prevent mouse events from firing
334
+
335
+ // Clear any pending close timeout
336
+ if (this.currentCloseTimeout) {
337
+ clearTimeout(this.currentCloseTimeout)
338
+ this.currentCloseTimeout = null
339
+ }
340
+
341
+ // Lock the tooltip and show overlay
342
+ this.currentLockState = true
343
+ if (this.overlay) {
344
+ this.overlay.style.display = 'block'
345
+ }
346
+
347
+ if (touchEvent.touches.length > 0) {
348
+ const touch = touchEvent.touches[0]
349
+ if (touch) {
350
+ const syntheticEvent = new MouseEvent('mouseenter', {
351
+ clientX: touch.clientX,
352
+ clientY: touch.clientY
353
+ })
354
+ this.handleMouseEnter(element as HTMLElement, reference, syntheticEvent)
355
+ isTooltipVisible = true
356
+ }
357
+ }
358
+ })
359
+
360
+ // Global click handler to close locked tooltip when clicking outside
361
+ const closeOnOutsideClick = (e: MouseEvent) => {
362
+ if (this.currentLockState && isTooltipVisible && this.tooltip && !this.tooltip.contains(e.target as Node) && !element.contains(e.target as Node)) {
363
+ this.hideTooltip()
364
+ isTooltipVisible = false
365
+ }
366
+ }
367
+ document.addEventListener('click', closeOnOutsideClick)
368
+ })
369
+ }
370
+
371
+ private initializeTooltips() {
372
+ // No initial scan - pages will trigger manually after ContentRenderer completes
373
+ // This prevents unnecessary duplicate scans
374
+ }
375
+ }
376
+
377
+ // Create global instance
378
+ const bibleTooltips = new BibleTooltips()
379
+
380
+ // Provide scan function for manual triggering after ContentRenderer completes
381
+ nuxtApp.provide('bibleTooltips', {
382
+ scan: (container?: HTMLElement) => bibleTooltips.scan(container)
383
+ })
384
+ })
385
+
386
+ // TypeScript declarations
387
+ declare module '#app' {
388
+ interface NuxtApp {
389
+ $bibleTooltips: {
390
+ scan: (container?: HTMLElement) => void
391
+ }
392
+ }
393
+ }
394
+
395
+ declare module '@vue/runtime-core' {
396
+ interface ComponentCustomProperties {
397
+ $bibleTooltips: {
398
+ scan: (container?: HTMLElement) => void
399
+ }
400
+ }
401
+ }
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createBibleReferencePatterns } from '../utils/bible-book-names'
3
+
4
+ /**
5
+ * Unit tests for Bible reference parsing regex patterns
6
+ */
7
+ describe('Bible Reference Parsing', () => {
8
+ const patterns = createBibleReferencePatterns()
9
+
10
+ function findAllMatches(text: string): string[] {
11
+ const matches: Array<{index: number, length: number, text: string}> = []
12
+
13
+ // First, find all explicit book references
14
+ patterns.forEach(pattern => {
15
+ let match
16
+ while ((match = pattern.exec(text)) !== null) {
17
+ const matchStart = match.index
18
+ const matchText = match[0]
19
+ const matchEnd = matchStart + matchText.length
20
+
21
+ // Check if this match overlaps with any existing match
22
+ const overlaps = matches.some(m =>
23
+ (matchStart >= m.index && matchStart < m.index + m.length) ||
24
+ (matchEnd > m.index && matchEnd <= m.index + m.length) ||
25
+ (matchStart <= m.index && matchEnd >= m.index + m.length)
26
+ )
27
+
28
+ if (!overlaps) {
29
+ matches.push({
30
+ index: matchStart,
31
+ length: matchText.length,
32
+ text: matchText
33
+ })
34
+ }
35
+ }
36
+ pattern.lastIndex = 0 // Reset regex
37
+ })
38
+
39
+ // Sort matches by index
40
+ matches.sort((a, b) => a.index - b.index)
41
+
42
+ // Now expand shorthand notation (e.g., "John 14:16,26" or "Revelation 1:5, 17:14")
43
+ const expanded: Array<{index: number, length: number, text: string}> = []
44
+
45
+ matches.forEach(match => {
46
+ const fullText = match.text
47
+
48
+ // Check if followed by comma-separated shorthand (e.g., ",26" or ", 17:14")
49
+ const afterMatch = text.substring(match.index + match.length)
50
+ const shorthandPattern = /^(?:,\s*(\d+(?::\d+)?(?:-\d+(?::\d+)?)?))*/
51
+ const shorthandMatch = afterMatch.match(shorthandPattern)
52
+
53
+ if (shorthandMatch && shorthandMatch[0].length > 0) {
54
+ // Extract book name and chapter from the original match
55
+ const refMatch = fullText.match(/^(.+?)\s+(\d+):(\d+)/)
56
+ if (refMatch) {
57
+ const book = refMatch[1]
58
+ const chapter = refMatch[2]
59
+
60
+ // Add the original match
61
+ expanded.push(match)
62
+
63
+ // Parse shorthand references
64
+ const shorthands = shorthandMatch[0].split(',').filter(s => s.trim())
65
+ let currentIndex = match.index + match.length
66
+
67
+ shorthands.forEach(shorthand => {
68
+ const trimmed = shorthand.trim()
69
+ if (trimmed) {
70
+ let expandedRef = ''
71
+ if (trimmed.includes(':')) {
72
+ // Chapter:verse format (e.g., "17:14")
73
+ expandedRef = `${book} ${trimmed}`
74
+ } else {
75
+ // Just verse number (e.g., "26")
76
+ expandedRef = `${book} ${chapter}:${trimmed}`
77
+ }
78
+
79
+ // Find the actual position of this shorthand in text
80
+ const shorthandIndex = text.indexOf(shorthand, currentIndex)
81
+ if (shorthandIndex !== -1) {
82
+ expanded.push({
83
+ index: shorthandIndex,
84
+ length: shorthand.length,
85
+ text: expandedRef
86
+ })
87
+ currentIndex = shorthandIndex + shorthand.length
88
+ }
89
+ }
90
+ })
91
+ } else {
92
+ // Not a chapter:verse pattern, just add original
93
+ expanded.push(match)
94
+ }
95
+ } else {
96
+ // No shorthand, just add original
97
+ expanded.push(match)
98
+ }
99
+ })
100
+
101
+ // Sort by index and return text only
102
+ expanded.sort((a, b) => a.index - b.index)
103
+ return expanded.map(m => m.text)
104
+ }
105
+
106
+ it('should parse single verse references', () => {
107
+ const text = 'Read John 3:16 for God\'s love.'
108
+ const matches = findAllMatches(text)
109
+ expect(matches).toEqual(['John 3:16'])
110
+ })
111
+
112
+ it('should parse shorthand comma-separated verses in same chapter', () => {
113
+ const text = 'See John 14:16,26 for the Helper.'
114
+ const matches = findAllMatches(text)
115
+ expect(matches).toEqual(['John 14:16', 'John 14:26'])
116
+ })
117
+
118
+ it('should parse shorthand comma-separated verses with different chapters', () => {
119
+ const text = 'Read Revelation 1:5, 17:14, 19:16 about Jesus.'
120
+ const matches = findAllMatches(text)
121
+ expect(matches).toEqual(['Revelation 1:5', 'Revelation 17:14', 'Revelation 19:16'])
122
+ })
123
+
124
+ it('should parse full references with commas', () => {
125
+ const text = 'See Matthew 10:28-33, Matthew 25:31-46 for judgment.'
126
+ const matches = findAllMatches(text)
127
+ expect(matches).toEqual(['Matthew 10:28-33', 'Matthew 25:31-46'])
128
+ })
129
+
130
+ it('should parse cross-chapter ranges', () => {
131
+ const text = 'Read 2 Corinthians 4:16-5:9 for encouragement.'
132
+ const matches = findAllMatches(text)
133
+ expect(matches).toEqual(['2 Corinthians 4:16-5:9'])
134
+ })
135
+
136
+ it('should parse same-chapter ranges', () => {
137
+ const text = 'John 3:16-18 is about salvation.'
138
+ const matches = findAllMatches(text)
139
+ expect(matches).toEqual(['John 3:16-18'])
140
+ })
141
+
142
+ it('should parse chapter-only references', () => {
143
+ const text = 'Psalm 23 is for comfort.'
144
+ const matches = findAllMatches(text)
145
+ expect(matches).toEqual(['Psalm 23'])
146
+ })
147
+
148
+ it('should parse multiple different reference types', () => {
149
+ const text = 'Genesis 1:1, Psalm 23, John 3:16-18, and 2 Corinthians 4:16-5:9.'
150
+ const matches = findAllMatches(text)
151
+ expect(matches).toEqual([
152
+ 'Genesis 1:1',
153
+ 'Psalm 23',
154
+ 'John 3:16-18',
155
+ '2 Corinthians 4:16-5:9'
156
+ ])
157
+ })
158
+
159
+ it('should not create overlapping matches', () => {
160
+ const text = 'Read (Ecclesiastes 1:2-4) for vanity.'
161
+ const matches = findAllMatches(text)
162
+ expect(matches).toEqual(['Ecclesiastes 1:2-4'])
163
+ })
164
+
165
+ it('should parse references with book names containing numbers', () => {
166
+ const text = 'Read 1 Corinthians 13:4-8 and 2 Timothy 3:16.'
167
+ const matches = findAllMatches(text)
168
+ expect(matches).toEqual(['1 Corinthians 13:4-8', '2 Timothy 3:16'])
169
+ })
170
+
171
+ it('should parse references in parentheses', () => {
172
+ const text = 'Our life is short (Ecclesiastes 1:2-4) compared to eternity.'
173
+ const matches = findAllMatches(text)
174
+ expect(matches).toEqual(['Ecclesiastes 1:2-4'])
175
+ })
176
+
177
+ it('should handle shorthand ranges in same chapter', () => {
178
+ const text = 'See Matthew 9:18-19,23-26 for miracles.'
179
+ const matches = findAllMatches(text)
180
+ expect(matches).toEqual(['Matthew 9:18-19', 'Matthew 9:23-26'])
181
+ })
182
+
183
+ it('should correctly identify match indices in parentheses', () => {
184
+ const text = 'Our life is short (Ecclesiastes 1:2-4) compared to eternity.'
185
+ const matches: Array<{index: number, length: number, text: string}> = []
186
+
187
+ patterns.forEach(pattern => {
188
+ let match
189
+ while ((match = pattern.exec(text)) !== null) {
190
+ matches.push({
191
+ index: match.index,
192
+ length: match[0].length,
193
+ text: match[0]
194
+ })
195
+ }
196
+ pattern.lastIndex = 0
197
+ })
198
+
199
+ expect(matches.length).toBe(1)
200
+ expect(matches[0]).toEqual({
201
+ index: 19,
202
+ length: 18,
203
+ text: 'Ecclesiastes 1:2-4'
204
+ })
205
+
206
+ // Verify reconstruction works
207
+ const match = matches[0]
208
+ if (match) {
209
+ const before = text.substring(0, match.index)
210
+ const after = text.substring(match.index + match.length)
211
+ const reconstructed = before + '[MATCH]' + after
212
+
213
+ expect(reconstructed).toBe('Our life is short ([MATCH]) compared to eternity.')
214
+ }
215
+ })
216
+
217
+ it('should correctly replace text in parentheses without losing brackets', () => {
218
+ const text = 'Our life is short (Ecclesiastes 1:2-4) compared to eternity.'
219
+
220
+ // Simulate the replacement logic from the plugin
221
+ const matches: Array<{index: number, length: number, text: string, displayText: string}> = []
222
+
223
+ patterns.forEach(pattern => {
224
+ let match: RegExpExecArray | null
225
+ while ((match = pattern.exec(text)) !== null) {
226
+ const matchIndex = match.index
227
+ const matchLength = match[0].length
228
+ const matchText = match[0]
229
+
230
+ const overlaps = matches.some(m =>
231
+ (matchIndex >= m.index && matchIndex < m.index + m.length) ||
232
+ (matchIndex + matchLength > m.index && matchIndex + matchLength <= m.index + m.length)
233
+ )
234
+
235
+ if (!overlaps) {
236
+ matches.push({
237
+ index: matchIndex,
238
+ length: matchLength,
239
+ text: matchText,
240
+ displayText: matchText
241
+ })
242
+ }
243
+ }
244
+ pattern.lastIndex = 0
245
+ })
246
+
247
+ // Sort in reverse order for replacement
248
+ matches.sort((a, b) => b.index - a.index)
249
+
250
+ // Replace from end to start
251
+ let newHTML = text
252
+ matches.forEach(match => {
253
+ const replacement = `<span class="bible-ref">${match.displayText}</span>`
254
+ newHTML = newHTML.substring(0, match.index) + replacement + newHTML.substring(match.index + match.length)
255
+ })
256
+
257
+ expect(newHTML).toBe('Our life is short (<span class="bible-ref">Ecclesiastes 1:2-4</span>) compared to eternity.')
258
+ })
259
+
260
+ it('should parse verse ranges followed by colon (list formatting)', () => {
261
+ const text = '3. Isaiah 53:3-12: "Pierced for our transgressions"'
262
+ const matches = findAllMatches(text)
263
+ expect(matches).toEqual(['Isaiah 53:3-12'])
264
+ })
265
+
266
+ it('should parse verse ranges followed by period', () => {
267
+ const text = 'Read Isaiah 53:3-12.'
268
+ const matches = findAllMatches(text)
269
+ expect(matches).toEqual(['Isaiah 53:3-12'])
270
+ })
271
+
272
+ it('should parse single verses followed by colon', () => {
273
+ const text = 'John 3:16: For God so loved the world'
274
+ const matches = findAllMatches(text)
275
+ expect(matches).toEqual(['John 3:16'])
276
+ })
277
+
278
+ it('should NOT match partial cross-chapter references', () => {
279
+ // Pattern 2 should NOT match "53:3-5" if it's actually "53:3-5:2"
280
+ const text = 'Isaiah 53:3-5:2 discusses suffering'
281
+ const matches = findAllMatches(text)
282
+ expect(matches).toEqual(['Isaiah 53:3-5:2']) // Should match Pattern 1, not Pattern 2
283
+ })
284
+ })
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Extended content types for @nuxt/content
3
+ */
4
+ declare module '@nuxt/content' {
5
+ interface ContentCollectionItem {
6
+ keywords?: string[]
7
+ }
8
+ }
9
+
10
+ export {}