Skip to content

Commit 868e672

Browse files
committed
refactor(verse): extract html utilities to shared module
Moves wrapVerseContent, extractNotesFromWrappedHtml, and related helpers from the Verse component to verse-html-utils.ts. This improves code organization and reduces component file size.
1 parent 2e2f17a commit 868e672

File tree

2 files changed

+242
-205
lines changed

2 files changed

+242
-205
lines changed

packages/ui/src/components/verse.tsx

Lines changed: 9 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,228 +1,32 @@
11
'use client';
22

3-
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
43
import { usePassage, useTheme } from '@youversion/platform-react-hooks';
54
import DOMPurify from 'isomorphic-dompurify';
65
import {
76
forwardRef,
87
memo,
8+
type ReactNode,
99
useEffect,
1010
useLayoutEffect,
1111
useRef,
1212
useState,
13-
type ReactNode,
1413
} from 'react';
1514
import { createPortal } from 'react-dom';
15+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
16+
import {
17+
extractNotesFromWrappedHtml,
18+
LETTERS,
19+
NON_BREAKING_SPACE,
20+
type VerseNotes,
21+
wrapVerseContent,
22+
} from '@/lib/verse-html-utils';
1623
import { Footnote } from './icons/footnote';
1724

18-
const NON_BREAKING_SPACE = '\u00A0';
19-
20-
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
21-
22-
type VerseNotes = {
23-
verseHtml: string;
24-
notes: string[];
25-
};
26-
2725
type ExtractedNotes = {
2826
html: string;
2927
notes: Record<string, VerseNotes>;
3028
};
3129

32-
/**
33-
* Wraps verse content in `yv-v` elements for easier CSS targeting.
34-
*
35-
* Transforms empty verse markers into wrapping containers. When a verse spans
36-
* multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern).
37-
*
38-
* Before: <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text...
39-
* After: <span class="yv-v" v="1"><span class="yv-vlbl">1</span>Text...</span>
40-
*
41-
* This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }`
42-
*/
43-
function wrapVerseContent(doc: Document): void {
44-
const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]'));
45-
if (!verseMarkers.length) return;
46-
47-
verseMarkers.forEach((marker, markerIndex) => {
48-
const verseNum = marker.getAttribute('v');
49-
if (!verseNum) return;
50-
51-
const nextMarker = verseMarkers[markerIndex + 1];
52-
const markerParent = marker.parentElement;
53-
if (!markerParent) return;
54-
55-
const nodesToWrap: Node[] = [];
56-
let currentNode: Node | null = marker.nextSibling;
57-
const currentParagraph = markerParent.closest('.p, p, div.p');
58-
59-
while (currentNode) {
60-
if (currentNode === nextMarker) break;
61-
if (nextMarker && currentNode instanceof Element && currentNode.contains(nextMarker)) break;
62-
63-
if (currentNode instanceof Element && currentNode.classList.contains('yv-h')) {
64-
currentNode = currentNode.nextSibling;
65-
continue;
66-
}
67-
68-
nodesToWrap.push(currentNode);
69-
currentNode = currentNode.nextSibling;
70-
}
71-
72-
if (nodesToWrap.length === 0) return;
73-
74-
const wrapper = doc.createElement('span');
75-
wrapper.className = 'yv-v';
76-
wrapper.setAttribute('v', verseNum);
77-
78-
const firstNode = nodesToWrap[0];
79-
if (firstNode) {
80-
marker.parentNode?.insertBefore(wrapper, firstNode);
81-
}
82-
nodesToWrap.forEach((node) => wrapper.appendChild(node));
83-
marker.remove();
84-
85-
if (!nextMarker) {
86-
wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph);
87-
} else {
88-
const nextMarkerParagraph = nextMarker.closest('.p, p, div.p');
89-
if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) {
90-
wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextMarkerParagraph);
91-
}
92-
}
93-
});
94-
}
95-
96-
/**
97-
* Wraps paragraphs between startParagraph and an optional endParagraph boundary.
98-
* If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted.
99-
*/
100-
function wrapParagraphsUntilBoundary(
101-
doc: Document,
102-
verseNum: string,
103-
startParagraph: Element | null,
104-
endParagraph?: Element | null,
105-
): void {
106-
if (!startParagraph) return;
107-
108-
let currentP: Element | null = startParagraph.nextElementSibling;
109-
110-
while (currentP && currentP !== endParagraph) {
111-
// Skip heading elements - these are structural, not verse content
112-
// See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift
113-
const isHeading =
114-
currentP.classList.contains('yv-h') ||
115-
currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r');
116-
if (isHeading) {
117-
currentP = currentP.nextElementSibling;
118-
continue;
119-
}
120-
121-
if (currentP.querySelector('.yv-v[v]')) break;
122-
123-
if (
124-
currentP.classList.contains('p') ||
125-
currentP.tagName === 'P' ||
126-
(currentP.tagName === 'DIV' && currentP.classList.contains('p'))
127-
) {
128-
wrapParagraphContent(doc, currentP, verseNum);
129-
}
130-
131-
currentP = currentP.nextElementSibling;
132-
}
133-
}
134-
135-
/**
136-
* Wraps all content in a paragraph with a verse span.
137-
*/
138-
function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void {
139-
const children = Array.from(paragraph.childNodes);
140-
if (children.length === 0) return;
141-
142-
const wrapper = doc.createElement('span');
143-
wrapper.className = 'yv-v';
144-
wrapper.setAttribute('v', verseNum);
145-
146-
const firstChild = children[0];
147-
if (firstChild) {
148-
paragraph.insertBefore(wrapper, firstChild);
149-
}
150-
children.forEach((child) => wrapper.appendChild(child));
151-
}
152-
153-
/**
154-
* Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers.
155-
*
156-
* This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent).
157-
* It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to.
158-
*
159-
* @returns Notes data for popovers, keyed by verse number
160-
*/
161-
function extractNotesFromWrappedHtml(doc: Document): Record<string, VerseNotes> {
162-
const footnotes = Array.from(doc.querySelectorAll('.yv-n.f'));
163-
if (!footnotes.length) return {};
164-
165-
// Group footnotes by verse number using closest wrapper
166-
const footnotesByVerse = new Map<string, Element[]>();
167-
footnotes.forEach((fn) => {
168-
const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v');
169-
if (verseNum) {
170-
let arr = footnotesByVerse.get(verseNum);
171-
if (!arr) {
172-
arr = [];
173-
footnotesByVerse.set(verseNum, arr);
174-
}
175-
arr.push(fn);
176-
}
177-
});
178-
179-
const notes: Record<string, VerseNotes> = {};
180-
181-
footnotesByVerse.forEach((fns, verseNum) => {
182-
// Find all wrappers for this verse (could be multiple if verse spans paragraphs)
183-
const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`));
184-
185-
// Build verse HTML with A/B/C markers for popover display
186-
let verseHtml = '';
187-
let noteIdx = 0;
188-
189-
verseWrappers.forEach((wrapper, wrapperIdx) => {
190-
if (wrapperIdx > 0) verseHtml += ' ';
191-
192-
const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
193-
while (walker.nextNode()) {
194-
const node = walker.currentNode;
195-
if (node instanceof Element) {
196-
if (node.classList.contains('yv-n') && node.classList.contains('f')) {
197-
verseHtml += `<sup class="yv:text-muted-foreground">${LETTERS[noteIdx++] || noteIdx}</sup>`;
198-
}
199-
} else if (node.nodeType === Node.TEXT_NODE) {
200-
const parent = node.parentElement;
201-
if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue;
202-
if (parent?.classList.contains('yv-vlbl')) continue;
203-
verseHtml += node.textContent || '';
204-
}
205-
}
206-
});
207-
208-
notes[verseNum] = {
209-
verseHtml,
210-
notes: fns.map((fn) => fn.innerHTML),
211-
};
212-
213-
// Insert placeholder at end of last verse wrapper
214-
const lastWrapper = verseWrappers[verseWrappers.length - 1];
215-
const placeholder = doc.createElement('span');
216-
placeholder.setAttribute('data-verse-footnote', verseNum);
217-
lastWrapper.appendChild(placeholder);
218-
});
219-
220-
// Remove all footnotes from DOM
221-
footnotes.forEach((fn) => fn.remove());
222-
223-
return notes;
224-
}
225-
22630
const VerseFootnoteButton = memo(function VerseFootnoteButton({
22731
verseNum,
22832
verseNotes,

0 commit comments

Comments
 (0)