From 65f97f022c97fe502a1ad99d94dd15f837fc08f2 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:03:00 -0600 Subject: [PATCH 01/13] chore(gitignore): add .opencode/plans to .gitignore This directory is used by the OpenCode tool and should not be committed to the repository. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 24df55d..f52839a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,5 @@ storybook-static !.yarn/sdks !.yarn/versions .yarn/install-state.gz + +.opencode/plans From ee77dbe818f526a4c87e82a05b847faf5d843b58 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:30:52 -0600 Subject: [PATCH 02/13] feat(ui): wrap verse content in yv-v elements This commit introduces logic to wrap verse content within `yv-v` elements. This change facilitates easier CSS targeting for verse-specific styling, such as highlighting. The implementation handles cases where verses span multiple paragraphs by duplicating the `yv-v` wrapper as per the Bible.com pattern. It also ensures that verse labels are preserved within these new wrappers and that header elements are not erroneously included. --- packages/ui/src/components/verse.test.tsx | 119 +++++++++++++++++ packages/ui/src/components/verse.tsx | 156 +++++++++++++++++++++- packages/ui/src/styles/bible-reader.css | 1 - 3 files changed, 271 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/verse.test.tsx b/packages/ui/src/components/verse.test.tsx index ffc191b..c2746b7 100644 --- a/packages/ui/src/components/verse.test.tsx +++ b/packages/ui/src/components/verse.test.tsx @@ -319,6 +319,125 @@ describe('Verse.Html - Footnotes', () => { }); }); +describe('Verse.Html - Verse Wrapping', () => { + it('should wrap verse content in yv-v elements', async () => { + const html = ` +
+ 1In the beginning was the Word. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verseWrapper = container.querySelector('.yv-v[v="1"]'); + expect(verseWrapper).not.toBeNull(); + expect(verseWrapper?.textContent).toContain('In the beginning was the Word'); + }); + }); + + it('should wrap multiple verses in same paragraph', async () => { + const html = ` +
+ 1First verse. + 2Second verse. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse1 = container.querySelector('.yv-v[v="1"]'); + const verse2 = container.querySelector('.yv-v[v="2"]'); + + expect(verse1).not.toBeNull(); + expect(verse2).not.toBeNull(); + expect(verse1?.textContent).toContain('First verse'); + expect(verse2?.textContent).toContain('Second verse'); + }); + }); + + it('should duplicate yv-v wrapper when verse spans multiple paragraphs', async () => { + const html = ` +
+ 39"Come," he replied. +
+
So they went and saw where he was staying.
+
+ 40Andrew was one of the two. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse39Wrappers = container.querySelectorAll('.yv-v[v="39"]'); + expect(verse39Wrappers.length).toBe(2); + + expect(verse39Wrappers[0]?.textContent).toContain('Come'); + expect(verse39Wrappers[1]?.textContent).toContain('So they went'); + }); + }); + + it('should enable CSS selection of individual verses', async () => { + const html = ` +
+ 1First verse text. + 2Second verse text. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verse1 = container.querySelector('.yv-v[v="1"]'); + const verse2 = container.querySelector('.yv-v[v="2"]'); + + expect(verse1).not.toBeNull(); + expect(verse2).not.toBeNull(); + expect(verse1).not.toBe(verse2); + }); + }); + + it('should preserve verse label inside wrapper', async () => { + const html = ` +
+ 5The light shines. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const verseWrapper = container.querySelector('.yv-v[v="5"]'); + const label = verseWrapper?.querySelector('.yv-vlbl'); + + expect(label).not.toBeNull(); + expect(label?.textContent).toContain('5'); + }); + }); + + it('should not wrap header elements in verse spans', async () => { + const html = ` +
+ 42And he brought him to Jesus. +
+
Jesus Calls Philip
+
+ 43The next day Jesus decided to leave. +
+ `; + + const { container } = render(); + + await waitFor(() => { + const header = container.querySelector('.yv-h'); + expect(header).not.toBeNull(); + expect(header?.closest('.yv-v')).toBeNull(); + }); + }); +}); + describe('Verse.Text', () => { it('should render verse with number and text (default size)', () => { const { container } = render(); diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 4b33257..91b1521 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -41,6 +41,151 @@ function isExcludedNode(node: Node): boolean { return false; } +/** + * Wraps verse content in `yv-v` elements for easier CSS targeting. + * + * Transforms empty verse markers into wrapping containers. When a verse spans + * multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern). + * + * Before: 1Text... + * After: 1Text... + * + * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` + */ +function wrapVerseContent(doc: Document): void { + const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); + if (!verseMarkers.length) return; + + verseMarkers.forEach((marker, markerIndex) => { + const verseNum = marker.getAttribute('v'); + if (!verseNum) return; + + const nextMarker = verseMarkers[markerIndex + 1]; + const markerParent = marker.parentElement; + if (!markerParent) return; + + const nodesToWrap: Node[] = []; + let currentNode: Node | null = marker.nextSibling; + const currentParagraph = markerParent.closest('.p, p, div.p'); + + while (currentNode) { + if (currentNode === nextMarker) break; + if (nextMarker && currentNode instanceof Element && currentNode.contains(nextMarker)) break; + + if (currentNode instanceof Element && currentNode.classList.contains('yv-h')) { + currentNode = currentNode.nextSibling; + continue; + } + + nodesToWrap.push(currentNode); + currentNode = currentNode.nextSibling; + } + + if (nodesToWrap.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstNode = nodesToWrap[0]; + if (firstNode) { + marker.parentNode?.insertBefore(wrapper, firstNode); + } + nodesToWrap.forEach((node) => wrapper.appendChild(node)); + marker.remove(); + + if (!nextMarker) { + wrapRemainingParagraphs(doc, verseNum, currentParagraph); + } else { + const nextMarkerParagraph = nextMarker.closest('.p, p, div.p'); + if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) { + wrapIntermediateParagraphs(doc, verseNum, currentParagraph, nextMarkerParagraph); + } + } + }); +} + +/** + * Wraps content in paragraphs between the current verse wrapper and the next verse marker. + */ +function wrapIntermediateParagraphs( + doc: Document, + verseNum: string, + startParagraph: Element, + endParagraph: Element, +): void { + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP && currentP !== endParagraph) { + if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { + currentP = currentP.nextElementSibling; + continue; + } + + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' || + (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + ) { + if (!currentP.querySelector('.yv-v[v]')) { + wrapParagraphContent(doc, currentP, verseNum); + } + } + + currentP = currentP.nextElementSibling; + } +} + +/** + * Wraps remaining paragraphs after the last verse marker. + */ +function wrapRemainingParagraphs( + doc: Document, + verseNum: string, + startParagraph: Element | null, +): void { + if (!startParagraph) return; + + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP) { + if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { + currentP = currentP.nextElementSibling; + continue; + } + + if (currentP.querySelector('.yv-v[v]')) break; + + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' || + (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + ) { + wrapParagraphContent(doc, currentP, verseNum); + } + + currentP = currentP.nextElementSibling; + } +} + +/** + * Wraps all content in a paragraph with a verse span. + */ +function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { + const children = Array.from(paragraph.childNodes); + if (children.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstChild = children[0]; + if (firstChild) { + paragraph.insertBefore(wrapper, firstChild); + } + children.forEach((child) => wrapper.appendChild(child)); +} + /** * Extracts footnotes from Bible HTML and prepares data for footnote popovers. * @@ -287,13 +432,16 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte const parser = new DOMParser(); const doc = parser.parseFromString(processedHtml, 'text/html'); + // Wrap verse content in yv-v elements for easier CSS targeting (e.g., highlights) + wrapVerseContent(doc); + // Adds non-breaking space to the end of verse labels for better copying and pasting // (i.e. "3For God so loved..." to "3 For God so loved...") - const paragraphs = doc.querySelectorAll('.yv-vlbl'); - paragraphs.forEach((p) => { - const text = p.textContent || ''; + const verseLabels = doc.querySelectorAll('.yv-vlbl'); + verseLabels.forEach((label) => { + const text = label.textContent || ''; if (!text.endsWith(NON_BREAKING_SPACE)) { - p.textContent = text + NON_BREAKING_SPACE; + label.textContent = text + NON_BREAKING_SPACE; } }); diff --git a/packages/ui/src/styles/bible-reader.css b/packages/ui/src/styles/bible-reader.css index 8fa4ef4..d7862a9 100644 --- a/packages/ui/src/styles/bible-reader.css +++ b/packages/ui/src/styles/bible-reader.css @@ -100,7 +100,6 @@ /* Wrap verse + label + content together to prevent breaking */ & .yv-v, & .verse { - white-space: nowrap; display: inline; } From f64388d7ec611c68acbbba80e472614b48305eeb Mon Sep 17 00:00:00 2001 From: cameronapak Date: Wed, 14 Jan 2026 10:38:48 -0600 Subject: [PATCH 03/13] refactor(footnotes): use wrapped verse html for extraction The extractNotesFromHtml function has been refactored into extractNotesFromWrappedHtml. This new function assumes that verse content has already been wrapped in .yv-v[v] elements by wrapVerseContent. This change simplifies the footnote extraction process by leveraging the existing verse wrapping and making the logic more straightforward. It also ensures footnotes are correctly associated with their respective verses, even when verses span multiple paragraphs. --- packages/ui/src/components/verse.tsx | 179 +++++++++------------------ 1 file changed, 60 insertions(+), 119 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 91b1521..bc7f247 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -29,18 +29,6 @@ type ExtractedNotes = { notes: Record; }; -/** - * Checks if a node should be excluded from verse text reconstruction. - * Excludes: verse markers (.yv-v), verse labels (.yv-vlbl), headers (.yv-h), and footnotes (.yv-n). - */ -function isExcludedNode(node: Node): boolean { - if (!(node instanceof Element)) return false; - if (node.classList.contains('yv-v') || node.classList.contains('yv-vlbl')) return true; - if (node.classList.contains('yv-h') || node.closest('.yv-h')) return true; - if (node.classList.contains('yv-n') || node.closest('.yv-n')) return true; - return false; -} - /** * Wraps verse content in `yv-v` elements for easier CSS targeting. * @@ -187,117 +175,76 @@ function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: strin } /** - * Extracts footnotes from Bible HTML and prepares data for footnote popovers. - * - * This function does three things: - * 1. Identifies verse boundaries using `.yv-v[v]` markers (verses can span multiple paragraphs) - * 2. For each verse with footnotes, builds a plain-text version with A/B/C markers for the popover - * 3. Inserts placeholder spans at the end of each verse (where the footnote icon will render) + * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. * - * The challenge: verses don't respect paragraph boundaries. A verse starts at `.yv-v[v="X"]` - * and ends at the next `.yv-v[v]` marker, potentially spanning multiple `
` elements. - * We use a TreeWalker to flatten the DOM into document order, then use index ranges to define verses. + * This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent). + * It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to. * - * @returns Modified HTML with footnotes removed and placeholders inserted, plus notes data for popovers + * @returns Notes data for popovers, keyed by verse number */ -function extractNotesFromHtml(html: string): ExtractedNotes { - if (typeof window === 'undefined') return { html, notes: {} }; +function extractNotesFromWrappedHtml(doc: Document): Record { + const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); + if (!footnotes.length) return {}; - const doc = new DOMParser().parseFromString( - DOMPurify.sanitize(html, DOMPURIFY_CONFIG), - 'text/html', - ); - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); - if (!verseMarkers.length) return { html: doc.body.innerHTML, notes: {} }; - - // Flatten DOM into document order so we can define verse boundaries by index ranges - const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); - const allNodes: Node[] = []; - do { - allNodes.push(walker.currentNode); - } while (walker.nextNode()); - - const nodeIndex = new Map(allNodes.map((n, i) => [n, i])); - const footnotes = doc.querySelectorAll('.yv-n.f'); - - // Define verse boundaries: each verse spans from its marker to the next marker (or end of content) - const verses = verseMarkers.map((marker, i) => { - const nextMarker = verseMarkers[i + 1]; - return { - num: marker.getAttribute('v') || '0', - start: nodeIndex.get(marker) ?? 0, - end: nextMarker ? (nodeIndex.get(nextMarker) ?? allNodes.length) : allNodes.length, - fns: [] as Element[], - }; - }); - - // Assign each footnote to its containing verse (find verse whose range contains the footnote) + // Group footnotes by verse number using closest wrapper + const footnotesByVerse = new Map(); footnotes.forEach((fn) => { - const idx = nodeIndex.get(fn); - if (idx !== undefined) { - const verse = [...verses].reverse().find((v) => idx > v.start); - if (verse) verse.fns.push(fn); + const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); + if (verseNum) { + if (!footnotesByVerse.has(verseNum)) { + footnotesByVerse.set(verseNum, []); + } + footnotesByVerse.get(verseNum)!.push(fn); } }); - const withNotes = verses.filter((v) => v.fns.length > 0); - const notes: Record = {}; - withNotes.forEach((verse) => { - // Build plain-text verse content for popover, replacing footnotes with A/B/C markers - let text = ''; + + footnotesByVerse.forEach((fns, verseNum) => { + // Find all wrappers for this verse (could be multiple if verse spans paragraphs) + const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`)); + + // Build verse HTML with A/B/C markers for popover display + let verseHtml = ''; let noteIdx = 0; - let lastP: Element | null = null; - for (let i = verse.start; i < verse.end; i++) { - const node = allNodes[i]; - if (!node) continue; - const parent = node.parentNode as Element | null; + verseWrappers.forEach((wrapper, wrapperIdx) => { + if (wrapperIdx > 0) verseHtml += ' '; - if (node instanceof Element) { - if (node.classList.contains('yv-h') || node.closest('.yv-h')) continue; - if (node.classList.contains('yv-n') && node.classList.contains('f')) { - text += `${LETTERS[noteIdx++] || noteIdx}`; + const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + if (node instanceof Element) { + if (node.classList.contains('yv-n') && node.classList.contains('f')) { + verseHtml += `${LETTERS[noteIdx++] || noteIdx}`; + } + } else if (node.nodeType === Node.TEXT_NODE) { + const parent = node.parentElement; + if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue; + if (parent?.classList.contains('yv-vlbl')) continue; + verseHtml += node.textContent || ''; } - } else if (node.nodeType === Node.TEXT_NODE && parent) { - if (parent.closest('.yv-h') || parent.closest('.yv-n.f')) continue; - if (parent.classList.contains('yv-v') || parent.classList.contains('yv-vlbl')) continue; - // Add space when transitioning between paragraphs (verses can span multiple

elements) - const curP = parent.closest('.p, p, div.p'); - if (lastP && curP && lastP !== curP) text += ' '; - text += node.textContent || ''; - if (curP) lastP = curP; } - } + }); - notes[verse.num] = { verseHtml: text, notes: verse.fns.map((fn) => fn.innerHTML) }; - - // Insert placeholder at end of verse content (walk backwards to find last text node) - for (let i = verse.end - 1; i > verse.start; i--) { - const node = allNodes[i]; - if (!node) continue; - const parent = node.parentNode as Element | null; - if ( - node.nodeType === Node.TEXT_NODE && - node.textContent?.trim() && - parent && - !isExcludedNode(parent) && - !parent.closest('.yv-n') && - !parent.closest('.yv-h') - ) { - const placeholder = doc.createElement('span'); - placeholder.setAttribute('data-verse-footnote', verse.num); - parent.insertBefore(placeholder, node.nextSibling); - break; - } + notes[verseNum] = { + verseHtml, + notes: fns.map((fn) => fn.innerHTML), + }; + + // Insert placeholder at end of last verse wrapper + const lastWrapper = verseWrappers[verseWrappers.length - 1]; + if (lastWrapper) { + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verseNum); + lastWrapper.appendChild(placeholder); } }); - footnotes.forEach((fn) => { - fn.remove(); - }); + // Remove all footnotes from DOM + footnotes.forEach((fn) => fn.remove()); - return { html: doc.body.innerHTML, notes }; + return notes; } const VerseFootnoteButton = memo(function VerseFootnoteButton({ @@ -417,24 +364,18 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte return { html, notes: {} }; } - let extractedNotes: Record = {}; - let processedHtml = html; - - if (extractNotes) { - const result = extractNotesFromHtml(html); - processedHtml = result.html; - extractedNotes = result.notes; - } else { - processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG); - } - - // Safely parse and modify HTML to add spaces to paragraph elements - const parser = new DOMParser(); - const doc = parser.parseFromString(processedHtml, 'text/html'); + // Parse and sanitize HTML + const doc = new DOMParser().parseFromString( + DOMPurify.sanitize(html, DOMPURIFY_CONFIG), + 'text/html', + ); - // Wrap verse content in yv-v elements for easier CSS targeting (e.g., highlights) + // Wrap verse content FIRST (enables simple footnote extraction) wrapVerseContent(doc); + // Extract footnotes using the wrapped verse structure + const extractedNotes = extractNotes ? extractNotesFromWrappedHtml(doc) : {}; + // Adds non-breaking space to the end of verse labels for better copying and pasting // (i.e. "3For God so loved..." to "3 For God so loved...") const verseLabels = doc.querySelectorAll('.yv-vlbl'); From a09af7cd257cf21baacf15929c03adc8d9095139 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Thu, 15 Jan 2026 09:41:07 -0600 Subject: [PATCH 04/13] refactor(verse): consolidate paragraph wrapper functions Combine `wrapIntermediateParagraphs` and `wrapRemainingParagraphs` into a single `wrapParagraphsUntilBoundary` function to reduce code duplication. The unified function handles both scenarios via an optional `endParagraph` parameter. --- packages/ui/src/components/verse.tsx | 43 +++++----------------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index bc7f247..6f531ed 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -83,60 +83,31 @@ function wrapVerseContent(doc: Document): void { marker.remove(); if (!nextMarker) { - wrapRemainingParagraphs(doc, verseNum, currentParagraph); + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); } else { const nextMarkerParagraph = nextMarker.closest('.p, p, div.p'); if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) { - wrapIntermediateParagraphs(doc, verseNum, currentParagraph, nextMarkerParagraph); + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextMarkerParagraph); } } }); } /** - * Wraps content in paragraphs between the current verse wrapper and the next verse marker. + * Wraps paragraphs between startParagraph and an optional endParagraph boundary. + * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. */ -function wrapIntermediateParagraphs( - doc: Document, - verseNum: string, - startParagraph: Element, - endParagraph: Element, -): void { - let currentP: Element | null = startParagraph.nextElementSibling; - - while (currentP && currentP !== endParagraph) { - if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { - currentP = currentP.nextElementSibling; - continue; - } - - if ( - currentP.classList.contains('p') || - currentP.tagName === 'P' || - (currentP.tagName === 'DIV' && currentP.classList.contains('p')) - ) { - if (!currentP.querySelector('.yv-v[v]')) { - wrapParagraphContent(doc, currentP, verseNum); - } - } - - currentP = currentP.nextElementSibling; - } -} - -/** - * Wraps remaining paragraphs after the last verse marker. - */ -function wrapRemainingParagraphs( +function wrapParagraphsUntilBoundary( doc: Document, verseNum: string, startParagraph: Element | null, + endParagraph?: Element | null, ): void { if (!startParagraph) return; let currentP: Element | null = startParagraph.nextElementSibling; - while (currentP) { + while (currentP && currentP !== endParagraph) { if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { currentP = currentP.nextElementSibling; continue; From a2806fb72325dafb87c5c379b7e308fa3e8a6396 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Thu, 15 Jan 2026 09:42:32 -0600 Subject: [PATCH 05/13] refactor: replace non-null assertion with safe Map access pattern --- packages/ui/src/components/verse.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 6f531ed..6e91b4a 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -162,10 +162,12 @@ function extractNotesFromWrappedHtml(doc: Document): Record footnotes.forEach((fn) => { const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); if (verseNum) { - if (!footnotesByVerse.has(verseNum)) { - footnotesByVerse.set(verseNum, []); + let arr = footnotesByVerse.get(verseNum); + if (!arr) { + arr = []; + footnotesByVerse.set(verseNum, arr); } - footnotesByVerse.get(verseNum)!.push(fn); + arr.push(fn); } }); From 0e16c0c10f0ed1c5f40bb718586100c131eb85ab Mon Sep 17 00:00:00 2001 From: cameronapak Date: Thu, 15 Jan 2026 09:44:32 -0600 Subject: [PATCH 06/13] refactor(ui): remove unnecessary null check in extractNotesFromWrappedHtml --- packages/ui/src/components/verse.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 6e91b4a..dc1bea5 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -206,12 +206,10 @@ function extractNotesFromWrappedHtml(doc: Document): Record }; // Insert placeholder at end of last verse wrapper - const lastWrapper = verseWrappers[verseWrappers.length - 1]; - if (lastWrapper) { - const placeholder = doc.createElement('span'); - placeholder.setAttribute('data-verse-footnote', verseNum); - lastWrapper.appendChild(placeholder); - } + const lastWrapper = verseWrappers[verseWrappers.length - 1]!; + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verseNum); + lastWrapper.appendChild(placeholder); }); // Remove all footnotes from DOM From 8efbe7658aad27f17c2416c8526b621861e06e9d Mon Sep 17 00:00:00 2001 From: cameronapak Date: Thu, 15 Jan 2026 09:54:04 -0600 Subject: [PATCH 07/13] fix(ui): expand USFM heading class detection in verse wrapping --- packages/ui/src/components/verse.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index dc1bea5..0629ec7 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -108,7 +108,12 @@ function wrapParagraphsUntilBoundary( let currentP: Element | null = startParagraph.nextElementSibling; while (currentP && currentP !== endParagraph) { - if (currentP.classList.contains('yv-h') || currentP.matches('.s1, .s2, .s3, .s4, .ms')) { + // Skip heading elements - these are structural, not verse content + // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift + const isHeading = + currentP.classList.contains('yv-h') || + currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); + if (isHeading) { currentP = currentP.nextElementSibling; continue; } From bc7a9d3cfe6b8bd7c4e163cce34bc86159503563 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Thu, 15 Jan 2026 09:57:54 -0600 Subject: [PATCH 08/13] refactor(ui): remove unnecessary non-null assertion in extractNotesFromWrappedHtml --- packages/ui/src/components/verse.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 0629ec7..f141e99 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -211,7 +211,7 @@ function extractNotesFromWrappedHtml(doc: Document): Record }; // Insert placeholder at end of last verse wrapper - const lastWrapper = verseWrappers[verseWrappers.length - 1]!; + const lastWrapper = verseWrappers[verseWrappers.length - 1]; const placeholder = doc.createElement('span'); placeholder.setAttribute('data-verse-footnote', verseNum); lastWrapper.appendChild(placeholder); From 0b35edff6e82f5c300b4e91c9f0a026e33048ba7 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Fri, 16 Jan 2026 11:22:29 -0600 Subject: [PATCH 09/13] 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. --- packages/ui/src/components/verse.tsx | 214 +--------------------- packages/ui/src/lib/verse-html-utils.ts | 233 ++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 205 deletions(-) create mode 100644 packages/ui/src/lib/verse-html-utils.ts diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index f141e99..a77f52c 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -1,228 +1,32 @@ 'use client'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { usePassage, useTheme } from '@youversion/platform-react-hooks'; import DOMPurify from 'isomorphic-dompurify'; import { forwardRef, memo, + type ReactNode, useEffect, useLayoutEffect, useRef, useState, - type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + extractNotesFromWrappedHtml, + LETTERS, + NON_BREAKING_SPACE, + type VerseNotes, + wrapVerseContent, +} from '@/lib/verse-html-utils'; import { Footnote } from './icons/footnote'; -const NON_BREAKING_SPACE = '\u00A0'; - -const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; - -type VerseNotes = { - verseHtml: string; - notes: string[]; -}; - type ExtractedNotes = { html: string; notes: Record; }; -/** - * Wraps verse content in `yv-v` elements for easier CSS targeting. - * - * Transforms empty verse markers into wrapping containers. When a verse spans - * multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern). - * - * Before: 1Text... - * After: 1Text... - * - * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` - */ -function wrapVerseContent(doc: Document): void { - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); - if (!verseMarkers.length) return; - - verseMarkers.forEach((marker, markerIndex) => { - const verseNum = marker.getAttribute('v'); - if (!verseNum) return; - - const nextMarker = verseMarkers[markerIndex + 1]; - const markerParent = marker.parentElement; - if (!markerParent) return; - - const nodesToWrap: Node[] = []; - let currentNode: Node | null = marker.nextSibling; - const currentParagraph = markerParent.closest('.p, p, div.p'); - - while (currentNode) { - if (currentNode === nextMarker) break; - if (nextMarker && currentNode instanceof Element && currentNode.contains(nextMarker)) break; - - if (currentNode instanceof Element && currentNode.classList.contains('yv-h')) { - currentNode = currentNode.nextSibling; - continue; - } - - nodesToWrap.push(currentNode); - currentNode = currentNode.nextSibling; - } - - if (nodesToWrap.length === 0) return; - - const wrapper = doc.createElement('span'); - wrapper.className = 'yv-v'; - wrapper.setAttribute('v', verseNum); - - const firstNode = nodesToWrap[0]; - if (firstNode) { - marker.parentNode?.insertBefore(wrapper, firstNode); - } - nodesToWrap.forEach((node) => wrapper.appendChild(node)); - marker.remove(); - - if (!nextMarker) { - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); - } else { - const nextMarkerParagraph = nextMarker.closest('.p, p, div.p'); - if (currentParagraph && nextMarkerParagraph && currentParagraph !== nextMarkerParagraph) { - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextMarkerParagraph); - } - } - }); -} - -/** - * Wraps paragraphs between startParagraph and an optional endParagraph boundary. - * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. - */ -function wrapParagraphsUntilBoundary( - doc: Document, - verseNum: string, - startParagraph: Element | null, - endParagraph?: Element | null, -): void { - if (!startParagraph) return; - - let currentP: Element | null = startParagraph.nextElementSibling; - - while (currentP && currentP !== endParagraph) { - // Skip heading elements - these are structural, not verse content - // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift - const isHeading = - currentP.classList.contains('yv-h') || - currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); - if (isHeading) { - currentP = currentP.nextElementSibling; - continue; - } - - if (currentP.querySelector('.yv-v[v]')) break; - - if ( - currentP.classList.contains('p') || - currentP.tagName === 'P' || - (currentP.tagName === 'DIV' && currentP.classList.contains('p')) - ) { - wrapParagraphContent(doc, currentP, verseNum); - } - - currentP = currentP.nextElementSibling; - } -} - -/** - * Wraps all content in a paragraph with a verse span. - */ -function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { - const children = Array.from(paragraph.childNodes); - if (children.length === 0) return; - - const wrapper = doc.createElement('span'); - wrapper.className = 'yv-v'; - wrapper.setAttribute('v', verseNum); - - const firstChild = children[0]; - if (firstChild) { - paragraph.insertBefore(wrapper, firstChild); - } - children.forEach((child) => wrapper.appendChild(child)); -} - -/** - * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. - * - * This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent). - * It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to. - * - * @returns Notes data for popovers, keyed by verse number - */ -function extractNotesFromWrappedHtml(doc: Document): Record { - const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); - if (!footnotes.length) return {}; - - // Group footnotes by verse number using closest wrapper - const footnotesByVerse = new Map(); - footnotes.forEach((fn) => { - const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); - if (verseNum) { - let arr = footnotesByVerse.get(verseNum); - if (!arr) { - arr = []; - footnotesByVerse.set(verseNum, arr); - } - arr.push(fn); - } - }); - - const notes: Record = {}; - - footnotesByVerse.forEach((fns, verseNum) => { - // Find all wrappers for this verse (could be multiple if verse spans paragraphs) - const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`)); - - // Build verse HTML with A/B/C markers for popover display - let verseHtml = ''; - let noteIdx = 0; - - verseWrappers.forEach((wrapper, wrapperIdx) => { - if (wrapperIdx > 0) verseHtml += ' '; - - const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); - while (walker.nextNode()) { - const node = walker.currentNode; - if (node instanceof Element) { - if (node.classList.contains('yv-n') && node.classList.contains('f')) { - verseHtml += `${LETTERS[noteIdx++] || noteIdx}`; - } - } else if (node.nodeType === Node.TEXT_NODE) { - const parent = node.parentElement; - if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue; - if (parent?.classList.contains('yv-vlbl')) continue; - verseHtml += node.textContent || ''; - } - } - }); - - notes[verseNum] = { - verseHtml, - notes: fns.map((fn) => fn.innerHTML), - }; - - // Insert placeholder at end of last verse wrapper - const lastWrapper = verseWrappers[verseWrappers.length - 1]; - const placeholder = doc.createElement('span'); - placeholder.setAttribute('data-verse-footnote', verseNum); - lastWrapper.appendChild(placeholder); - }); - - // Remove all footnotes from DOM - footnotes.forEach((fn) => fn.remove()); - - return notes; -} - const VerseFootnoteButton = memo(function VerseFootnoteButton({ verseNum, verseNotes, diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts new file mode 100644 index 0000000..b022d37 --- /dev/null +++ b/packages/ui/src/lib/verse-html-utils.ts @@ -0,0 +1,233 @@ +export const NON_BREAKING_SPACE = '\u00A0'; + +export const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; + +export type VerseNotes = { + verseHtml: string; + notes: string[]; +}; + +/** + * Wraps verse content in `yv-v` elements for easier CSS targeting. + * + * Transforms empty verse markers into wrapping containers. When a verse spans + * multiple paragraphs, creates duplicate wrappers in each paragraph (Bible.com pattern). + * + * Before: 1Text... + * After: 1Text... + * + * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` + */ +export function wrapVerseContent(doc: Document): void { + const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); + verseMarkers.forEach(processVerseMarker); +} + +function processVerseMarker(marker: Element, index: number, markers: Element[]): void { + const verseNum = marker.getAttribute('v'); + if (!verseNum) return; + + const nodesToWrap = collectNodesBetweenMarkers(marker, markers[index + 1]); + if (nodesToWrap.length === 0) return; + + wrapNodesInVerse(marker, verseNum, nodesToWrap); + handleParagraphWrapping(marker, markers[index + 1], verseNum); +} + +function collectNodesBetweenMarkers(startMarker: Element, endMarker: Element | undefined): Node[] { + const nodes: Node[] = []; + let current: Node | null = startMarker.nextSibling; + + while (current && !shouldStopCollecting(current, endMarker)) { + if (shouldSkipNode(current)) { + current = current.nextSibling; + continue; + } + nodes.push(current); + current = current.nextSibling; + } + + return nodes; +} + +function shouldStopCollecting(node: Node, endMarker: Element | undefined): boolean { + if (node === endMarker) return true; + if (endMarker && node instanceof Element && node.contains(endMarker)) return true; + return false; +} + +function shouldSkipNode(node: Node): boolean { + return node instanceof Element && node.classList.contains('yv-h'); +} + +function wrapNodesInVerse(marker: Element, verseNum: string, nodes: Node[]): void { + const wrapper = marker.ownerDocument.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstNode = nodes[0]; + if (firstNode) { + marker.parentNode?.insertBefore(wrapper, firstNode); + } + + nodes.forEach((node) => { + wrapper.appendChild(node); + }); + marker.remove(); +} + +function handleParagraphWrapping( + marker: Element, + nextMarker: Element | undefined, + verseNum: string | null, +): void { + const doc = marker.ownerDocument; + const currentParagraph = marker.closest('.p, p, div.p'); + if (!currentParagraph || !verseNum) return; + + if (!nextMarker) { + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); + return; + } + + const nextParagraph = nextMarker.closest('.p, p, div.p'); + if (nextParagraph && currentParagraph !== nextParagraph) { + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextParagraph); + } +} + +/** + * Wraps paragraphs between startParagraph and an optional endParagraph boundary. + * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. + */ +function wrapParagraphsUntilBoundary( + doc: Document, + verseNum: string, + startParagraph: Element | null, + endParagraph?: Element | null, +): void { + if (!startParagraph) return; + + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP && currentP !== endParagraph) { + // Skip heading elements - these are structural, not verse content + // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift + const isHeading = + currentP.classList.contains('yv-h') || + currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); + if (isHeading) { + currentP = currentP.nextElementSibling; + continue; + } + + if (currentP.querySelector('.yv-v[v]')) break; + + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' || + (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + ) { + wrapParagraphContent(doc, currentP, verseNum); + } + + currentP = currentP.nextElementSibling; + } +} + +/** + * Wraps all content in a paragraph with a verse span. + */ +function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { + const children = Array.from(paragraph.childNodes); + if (children.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstChild = children[0]; + if (firstChild) { + paragraph.insertBefore(wrapper, firstChild); + } + children.forEach((child) => { + wrapper.appendChild(child); + }); +} + +/** + * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. + * + * This function assumes verses are already wrapped in `.yv-v[v]` elements (by wrapVerseContent). + * It uses `.closest('.yv-v[v]')` to find which verse each footnote belongs to. + * + * @returns Notes data for popovers, keyed by verse number + */ +export function extractNotesFromWrappedHtml(doc: Document): Record { + const footnotes = Array.from(doc.querySelectorAll('.yv-n.f')); + if (!footnotes.length) return {}; + + // Group footnotes by verse number using closest wrapper + const footnotesByVerse = new Map(); + footnotes.forEach((fn) => { + const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v'); + if (verseNum) { + let arr = footnotesByVerse.get(verseNum); + if (!arr) { + arr = []; + footnotesByVerse.set(verseNum, arr); + } + arr.push(fn); + } + }); + + const notes: Record = {}; + + footnotesByVerse.forEach((fns, verseNum) => { + // Find all wrappers for this verse (could be multiple if verse spans paragraphs) + const verseWrappers = Array.from(doc.querySelectorAll(`.yv-v[v="${verseNum}"]`)); + + // Build verse HTML with A/B/C markers for popover display + let verseHtml = ''; + let noteIdx = 0; + + verseWrappers.forEach((wrapper, wrapperIdx) => { + if (wrapperIdx > 0) verseHtml += ' '; + + const walker = doc.createTreeWalker(wrapper, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + if (node instanceof Element) { + if (node.classList.contains('yv-n') && node.classList.contains('f')) { + verseHtml += `${LETTERS[noteIdx++] || noteIdx}`; + } + } else if (node.nodeType === Node.TEXT_NODE) { + const parent = node.parentElement; + if (parent?.closest('.yv-n.f') || parent?.closest('.yv-h')) continue; + if (parent?.classList.contains('yv-vlbl')) continue; + verseHtml += node.textContent || ''; + } + } + }); + + notes[verseNum] = { + verseHtml, + notes: fns.map((fn) => fn.innerHTML), + }; + + // Insert placeholder at end of last verse wrapper + const lastWrapper = verseWrappers[verseWrappers.length - 1]; + if (lastWrapper) { + const placeholder = doc.createElement('span'); + placeholder.setAttribute('data-verse-footnote', verseNum); + lastWrapper.appendChild(placeholder); + } + }); + + // Remove all footnotes from DOM + footnotes.forEach((fn) => { + fn.remove(); + }); + + return notes; +} From 5b37bbad6ce99133952ce852746e39fe70647bce Mon Sep 17 00:00:00 2001 From: cameronapak Date: Fri, 16 Jan 2026 11:24:36 -0600 Subject: [PATCH 10/13] fix: rm redundant if statement conditional --- packages/ui/src/lib/verse-html-utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts index b022d37..7ed0119 100644 --- a/packages/ui/src/lib/verse-html-utils.ts +++ b/packages/ui/src/lib/verse-html-utils.ts @@ -125,8 +125,7 @@ function wrapParagraphsUntilBoundary( if ( currentP.classList.contains('p') || - currentP.tagName === 'P' || - (currentP.tagName === 'DIV' && currentP.classList.contains('p')) + currentP.tagName === 'P' ) { wrapParagraphContent(doc, currentP, verseNum); } From 6dd41308e3f962659433ae923087edace641c48d Mon Sep 17 00:00:00 2001 From: cameronapak Date: Fri, 16 Jan 2026 11:35:59 -0600 Subject: [PATCH 11/13] refactor(wrapVerseContent): consolidate into single function These functions lack meaningful context when isolated, so consolidating them improves code clarity and maintainability. --- packages/ui/src/lib/verse-html-utils.ts | 217 ++++++++++++------------ 1 file changed, 109 insertions(+), 108 deletions(-) diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts index 7ed0119..68fcc1a 100644 --- a/packages/ui/src/lib/verse-html-utils.ts +++ b/packages/ui/src/lib/verse-html-utils.ts @@ -19,141 +19,142 @@ export type VerseNotes = { * This enables simple CSS selectors like `.yv-v[v="1"] { background: yellow; }` */ export function wrapVerseContent(doc: Document): void { - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); - verseMarkers.forEach(processVerseMarker); -} - -function processVerseMarker(marker: Element, index: number, markers: Element[]): void { - const verseNum = marker.getAttribute('v'); - if (!verseNum) return; + /** + * Wraps all content in a paragraph with a verse span. + */ + function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { + const children = Array.from(paragraph.childNodes); + if (children.length === 0) return; + + const wrapper = doc.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); + + const firstChild = children[0]; + if (firstChild) { + paragraph.insertBefore(wrapper, firstChild); + } + children.forEach((child) => { + wrapper.appendChild(child); + }); + } - const nodesToWrap = collectNodesBetweenMarkers(marker, markers[index + 1]); - if (nodesToWrap.length === 0) return; + /** + * Wraps paragraphs between startParagraph and an optional endParagraph boundary. + * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. + */ + function wrapParagraphsUntilBoundary( + doc: Document, + verseNum: string, + startParagraph: Element | null, + endParagraph?: Element | null, + ): void { + if (!startParagraph) return; + + let currentP: Element | null = startParagraph.nextElementSibling; + + while (currentP && currentP !== endParagraph) { + // Skip heading elements - these are structural, not verse content + // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift + const isHeading = + currentP.classList.contains('yv-h') || + currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); + if (isHeading) { + currentP = currentP.nextElementSibling; + continue; + } - wrapNodesInVerse(marker, verseNum, nodesToWrap); - handleParagraphWrapping(marker, markers[index + 1], verseNum); -} + if (currentP.querySelector('.yv-v[v]')) break; -function collectNodesBetweenMarkers(startMarker: Element, endMarker: Element | undefined): Node[] { - const nodes: Node[] = []; - let current: Node | null = startMarker.nextSibling; + if ( + currentP.classList.contains('p') || + currentP.tagName === 'P' + ) { + wrapParagraphContent(doc, currentP, verseNum); + } - while (current && !shouldStopCollecting(current, endMarker)) { - if (shouldSkipNode(current)) { - current = current.nextSibling; - continue; + currentP = currentP.nextElementSibling; } - nodes.push(current); - current = current.nextSibling; } - return nodes; -} + function handleParagraphWrapping( + marker: Element, + nextMarker: Element | undefined, + verseNum: string | null, + ): void { + const doc = marker.ownerDocument; + const currentParagraph = marker.closest('.p, p, div.p'); + if (!currentParagraph || !verseNum) return; + + if (!nextMarker) { + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); + return; + } -function shouldStopCollecting(node: Node, endMarker: Element | undefined): boolean { - if (node === endMarker) return true; - if (endMarker && node instanceof Element && node.contains(endMarker)) return true; - return false; -} + const nextParagraph = nextMarker.closest('.p, p, div.p'); + if (nextParagraph && currentParagraph !== nextParagraph) { + wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextParagraph); + } + } -function shouldSkipNode(node: Node): boolean { - return node instanceof Element && node.classList.contains('yv-h'); -} + function wrapNodesInVerse(marker: Element, verseNum: string, nodes: Node[]): void { + const wrapper = marker.ownerDocument.createElement('span'); + wrapper.className = 'yv-v'; + wrapper.setAttribute('v', verseNum); -function wrapNodesInVerse(marker: Element, verseNum: string, nodes: Node[]): void { - const wrapper = marker.ownerDocument.createElement('span'); - wrapper.className = 'yv-v'; - wrapper.setAttribute('v', verseNum); + const firstNode = nodes[0]; + if (firstNode) { + marker.parentNode?.insertBefore(wrapper, firstNode); + } - const firstNode = nodes[0]; - if (firstNode) { - marker.parentNode?.insertBefore(wrapper, firstNode); + nodes.forEach((node) => { + wrapper.appendChild(node); + }); + marker.remove(); } - nodes.forEach((node) => { - wrapper.appendChild(node); - }); - marker.remove(); -} - -function handleParagraphWrapping( - marker: Element, - nextMarker: Element | undefined, - verseNum: string | null, -): void { - const doc = marker.ownerDocument; - const currentParagraph = marker.closest('.p, p, div.p'); - if (!currentParagraph || !verseNum) return; - - if (!nextMarker) { - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); - return; + function shouldStopCollecting(node: Node, endMarker: Element | undefined): boolean { + if (node === endMarker) return true; + if (endMarker && node instanceof Element && node.contains(endMarker)) return true; + return false; } - const nextParagraph = nextMarker.closest('.p, p, div.p'); - if (nextParagraph && currentParagraph !== nextParagraph) { - wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextParagraph); + function shouldSkipNode(node: Node): boolean { + return node instanceof Element && node.classList.contains('yv-h'); } -} - -/** - * Wraps paragraphs between startParagraph and an optional endParagraph boundary. - * If no endParagraph is provided, wraps until a verse marker is found or siblings are exhausted. - */ -function wrapParagraphsUntilBoundary( - doc: Document, - verseNum: string, - startParagraph: Element | null, - endParagraph?: Element | null, -): void { - if (!startParagraph) return; - - let currentP: Element | null = startParagraph.nextElementSibling; - - while (currentP && currentP !== endParagraph) { - // Skip heading elements - these are structural, not verse content - // See iOS implementation: https://github.com/youversion/platform-sdk-swift/blob/main/Sources/YouVersionPlatformUI/Views/Rendering/BibleVersionRendering.swift - const isHeading = - currentP.classList.contains('yv-h') || - currentP.matches('.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r'); - if (isHeading) { - currentP = currentP.nextElementSibling; - continue; - } - if (currentP.querySelector('.yv-v[v]')) break; + function collectNodesBetweenMarkers(startMarker: Element, endMarker: Element | undefined): Node[] { + const nodes: Node[] = []; + let current: Node | null = startMarker.nextSibling; - if ( - currentP.classList.contains('p') || - currentP.tagName === 'P' - ) { - wrapParagraphContent(doc, currentP, verseNum); + while (current && !shouldStopCollecting(current, endMarker)) { + if (shouldSkipNode(current)) { + current = current.nextSibling; + continue; + } + nodes.push(current); + current = current.nextSibling; } - currentP = currentP.nextElementSibling; + return nodes; } -} -/** - * Wraps all content in a paragraph with a verse span. - */ -function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { - const children = Array.from(paragraph.childNodes); - if (children.length === 0) return; + function processVerseMarker(marker: Element, index: number, markers: Element[]): void { + const verseNum = marker.getAttribute('v'); + if (!verseNum) return; - const wrapper = doc.createElement('span'); - wrapper.className = 'yv-v'; - wrapper.setAttribute('v', verseNum); + const nodesToWrap = collectNodesBetweenMarkers(marker, markers[index + 1]); + if (nodesToWrap.length === 0) return; - const firstChild = children[0]; - if (firstChild) { - paragraph.insertBefore(wrapper, firstChild); + wrapNodesInVerse(marker, verseNum, nodesToWrap); + handleParagraphWrapping(marker, markers[index + 1], verseNum); } - children.forEach((child) => { - wrapper.appendChild(child); - }); + + const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); + verseMarkers.forEach(processVerseMarker); } + /** * Extracts footnotes from wrapped verse HTML and prepares data for footnote popovers. * From 798894edb2c7636cf1c7cf8b5cae0e27b2070313 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Fri, 16 Jan 2026 12:38:00 -0600 Subject: [PATCH 12/13] fix(verse): collect paragraph context before removing marker The paragraph wrapping logic was failing because `handleParagraphWrapping` attempted to find the paragraph context after `wrapNodesInVerse` had removed the marker from the DOM. This caused `marker.closest('.p')` to return null, breaking verse wrapping for multi-paragraph verses. --- packages/ui/src/lib/verse-html-utils.ts | 44 ++++++++++++++----------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/lib/verse-html-utils.ts b/packages/ui/src/lib/verse-html-utils.ts index 68fcc1a..be52643 100644 --- a/packages/ui/src/lib/verse-html-utils.ts +++ b/packages/ui/src/lib/verse-html-utils.ts @@ -78,25 +78,40 @@ export function wrapVerseContent(doc: Document): void { } function handleParagraphWrapping( - marker: Element, - nextMarker: Element | undefined, - verseNum: string | null, + doc: Document, + currentParagraph: Element | null, + nextParagraph: Element | null, + verseNum: string, ): void { - const doc = marker.ownerDocument; - const currentParagraph = marker.closest('.p, p, div.p'); - if (!currentParagraph || !verseNum) return; + if (!currentParagraph) return; - if (!nextMarker) { + if (!nextParagraph) { wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph); return; } - const nextParagraph = nextMarker.closest('.p, p, div.p'); - if (nextParagraph && currentParagraph !== nextParagraph) { + if (currentParagraph !== nextParagraph) { wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextParagraph); } } + function processVerseMarker(marker: Element, index: number, markers: Element[]): void { + const verseNum = marker.getAttribute('v'); + if (!verseNum) return; + + const nextMarker = markers[index + 1]; + + const nodesToWrap = collectNodesBetweenMarkers(marker, nextMarker); + if (nodesToWrap.length === 0) return; + + const currentParagraph = marker.closest('.p, p, div.p'); + const nextParagraph = nextMarker?.closest('.p, p, div.p') || null; + const doc = marker.ownerDocument; + + wrapNodesInVerse(marker, verseNum, nodesToWrap); + handleParagraphWrapping(doc, currentParagraph, nextParagraph, verseNum); + } + function wrapNodesInVerse(marker: Element, verseNum: string, nodes: Node[]): void { const wrapper = marker.ownerDocument.createElement('span'); wrapper.className = 'yv-v'; @@ -139,17 +154,6 @@ export function wrapVerseContent(doc: Document): void { return nodes; } - function processVerseMarker(marker: Element, index: number, markers: Element[]): void { - const verseNum = marker.getAttribute('v'); - if (!verseNum) return; - - const nodesToWrap = collectNodesBetweenMarkers(marker, markers[index + 1]); - if (nodesToWrap.length === 0) return; - - wrapNodesInVerse(marker, verseNum, nodesToWrap); - handleParagraphWrapping(marker, markers[index + 1], verseNum); - } - const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]')); verseMarkers.forEach(processVerseMarker); } From dc3f52d565ca94169552830b77c64251cca92785 Mon Sep 17 00:00:00 2001 From: cameronapak Date: Fri, 16 Jan 2026 13:40:15 -0600 Subject: [PATCH 13/13] Docs: Added changeset --- .changeset/fast-places-juggle.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fast-places-juggle.md diff --git a/.changeset/fast-places-juggle.md b/.changeset/fast-places-juggle.md new file mode 100644 index 0000000..3446273 --- /dev/null +++ b/.changeset/fast-places-juggle.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-react-ui': minor +'@youversion/platform-core': minor +'@youversion/platform-react-hooks': minor +--- + +Refactor verse HTML transformation to support verse-level highlighting. Extract HTML processing logic to `verse-html-utils.ts` with new `wrapVerseContent()` function that wraps verse content in CSS-targetable `` elements. Simplify footnote extraction using wrapped verse structure. Remove CSS rule preventing text wrapping. Add comprehensive test coverage for verse wrapping behavior.