@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.
- package/README.md +228 -0
- package/bin/mdsite.js +3 -0
- package/dist/commands/commands.test.d.ts +1 -0
- package/dist/commands/commands.test.js +302 -0
- package/dist/commands/commands.test.js.map +1 -0
- package/dist/commands/generate.d.ts +1 -0
- package/dist/commands/generate.js +20 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +16 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/prepare.d.ts +1 -0
- package/dist/commands/prepare.js +111 -0
- package/dist/commands/prepare.js.map +1 -0
- package/dist/commands/prepare.test.d.ts +1 -0
- package/dist/commands/prepare.test.js +131 -0
- package/dist/commands/prepare.test.js.map +1 -0
- package/dist/commands/preview.d.ts +5 -0
- package/dist/commands/preview.js +62 -0
- package/dist/commands/preview.js.map +1 -0
- package/dist/commands/start.d.ts +5 -0
- package/dist/commands/start.js +50 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +26 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/workflows.test.d.ts +1 -0
- package/dist/commands/workflows.test.js +183 -0
- package/dist/commands/workflows.test.js.map +1 -0
- package/dist/config/default-mdsite-config.d.ts +2 -0
- package/dist/config/default-mdsite-config.js +82 -0
- package/dist/config/default-mdsite-config.js.map +1 -0
- package/dist/config/default-mdsite-config.test.d.ts +1 -0
- package/dist/config/default-mdsite-config.test.js +42 -0
- package/dist/config/default-mdsite-config.test.js.map +1 -0
- package/dist/config/mdsite-config.d.ts +42 -0
- package/dist/config/mdsite-config.js +74 -0
- package/dist/config/mdsite-config.js.map +1 -0
- package/dist/config/mdsite-config.test.d.ts +1 -0
- package/dist/config/mdsite-config.test.js +115 -0
- package/dist/config/mdsite-config.test.js.map +1 -0
- package/dist/config/menu.d.ts +3 -0
- package/dist/config/menu.js +58 -0
- package/dist/config/menu.js.map +1 -0
- package/dist/config/menu.test.d.ts +1 -0
- package/dist/config/menu.test.js +53 -0
- package/dist/config/menu.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +133 -0
- package/dist/index.test.js.map +1 -0
- package/dist/process/child-process.d.ts +9 -0
- package/dist/process/child-process.js +142 -0
- package/dist/process/child-process.js.map +1 -0
- package/dist/process/child-process.test.d.ts +1 -0
- package/dist/process/child-process.test.js +131 -0
- package/dist/process/child-process.test.js.map +1 -0
- package/dist/process/runtime-state.d.ts +16 -0
- package/dist/process/runtime-state.js +42 -0
- package/dist/process/runtime-state.js.map +1 -0
- package/dist/process/runtime-state.test.d.ts +1 -0
- package/dist/process/runtime-state.test.js +57 -0
- package/dist/process/runtime-state.test.js.map +1 -0
- package/dist/renderer/mdsite-nuxt.d.ts +22 -0
- package/dist/renderer/mdsite-nuxt.js +194 -0
- package/dist/renderer/mdsite-nuxt.js.map +1 -0
- package/dist/renderer/mdsite-nuxt.test.d.ts +1 -0
- package/dist/renderer/mdsite-nuxt.test.js +226 -0
- package/dist/renderer/mdsite-nuxt.test.js.map +1 -0
- package/mdsite-nuxt/app/app.vue +7 -0
- package/mdsite-nuxt/app/assets/css/bible-tooltips.css +87 -0
- package/mdsite-nuxt/app/assets/css/markdown.css +195 -0
- package/mdsite-nuxt/app/assets/css/print.css +133 -0
- package/mdsite-nuxt/app/components/AppBar.vue +138 -0
- package/mdsite-nuxt/app/components/AppFooter.vue +88 -0
- package/mdsite-nuxt/app/components/AppNavigation.vue +162 -0
- package/mdsite-nuxt/app/components/AppTableOfContents.vue +149 -0
- package/mdsite-nuxt/app/components/BreadcrumbNav.vue +139 -0
- package/mdsite-nuxt/app/components/NavigationTree.vue +169 -0
- package/mdsite-nuxt/app/components/SearchBox.vue +212 -0
- package/mdsite-nuxt/app/components/TocItem.vue +75 -0
- package/mdsite-nuxt/app/components/TreeNode.vue +264 -0
- package/mdsite-nuxt/app/components/content/MarkdownAlert.vue +42 -0
- package/mdsite-nuxt/app/components/content/Mermaid.vue +103 -0
- package/mdsite-nuxt/app/components/content/ProseA.vue +57 -0
- package/mdsite-nuxt/app/components/content/ProseBlockquote.vue +14 -0
- package/mdsite-nuxt/app/components/content/ProsePre.vue +49 -0
- package/mdsite-nuxt/app/components/content/ProseTable.vue +143 -0
- package/mdsite-nuxt/app/composables/useAppTheme.ts +89 -0
- package/mdsite-nuxt/app/composables/useBibleTooltips.ts +21 -0
- package/mdsite-nuxt/app/composables/useBreadcrumbs.ts +70 -0
- package/mdsite-nuxt/app/composables/useContentPostProcessing.ts +89 -0
- package/mdsite-nuxt/app/composables/useNavigationTree.ts +174 -0
- package/mdsite-nuxt/app/composables/useSearchIndex.ts +47 -0
- package/mdsite-nuxt/app/composables/useSearchRelevance.test.ts +255 -0
- package/mdsite-nuxt/app/composables/useSearchRelevance.ts +207 -0
- package/mdsite-nuxt/app/composables/useSidebarState.ts +62 -0
- package/mdsite-nuxt/app/composables/useSiteConfig.ts +37 -0
- package/mdsite-nuxt/app/composables/useSourceEdit.ts +69 -0
- package/mdsite-nuxt/app/composables/useTableOfContents.ts +141 -0
- package/mdsite-nuxt/app/composables/useTableParser.ts +106 -0
- package/mdsite-nuxt/app/composables/useTooltipConfig.ts +20 -0
- package/mdsite-nuxt/app/config/themes.ts +17 -0
- package/mdsite-nuxt/app/layouts/default.vue +376 -0
- package/mdsite-nuxt/app/pages/[...slug].vue +56 -0
- package/mdsite-nuxt/app/pages/index.vue +54 -0
- package/mdsite-nuxt/app/plugins/bible-tooltips.client.ts +401 -0
- package/mdsite-nuxt/app/plugins/bible-tooltips.test.ts +284 -0
- package/mdsite-nuxt/app/types/content.d.ts +10 -0
- package/mdsite-nuxt/app/types/table.ts +57 -0
- package/mdsite-nuxt/app/utils/bible-book-names.ts +37 -0
- package/mdsite-nuxt/app/utils/bible-verse-utils.test.ts +148 -0
- package/mdsite-nuxt/app/utils/bible-verse-utils.ts +236 -0
- package/mdsite-nuxt/content.config.ts +18 -0
- package/mdsite-nuxt/example.config.yml +67 -0
- package/mdsite-nuxt/nuxt.config.ts +247 -0
- package/mdsite-nuxt/package-lock.json +15922 -0
- package/mdsite-nuxt/package.json +50 -0
- package/mdsite-nuxt/playwright.config.js +47 -0
- package/mdsite-nuxt/public/robots.txt +2 -0
- package/mdsite-nuxt/scripts/generate-favicons.ts +192 -0
- package/mdsite-nuxt/scripts/generate-indices.ts +508 -0
- package/mdsite-nuxt/scripts/renderer-hooks.test.ts +277 -0
- package/mdsite-nuxt/scripts/renderer-hooks.ts +141 -0
- package/mdsite-nuxt/scripts/start.test.ts +164 -0
- package/mdsite-nuxt/scripts/start.ts +55 -0
- package/mdsite-nuxt/scripts/sync-content.ts +504 -0
- package/mdsite-nuxt/tsconfig.json +18 -0
- package/mdsite-nuxt/utils/mdsite-config.ts +240 -0
- package/mdsite-nuxt/vitest.config.ts +7 -0
- 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
|
+
})
|