Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fast-places-juggle.md
Original file line number Diff line number Diff line change
@@ -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 `<span class="yv-v">` elements. Simplify footnote extraction using wrapped verse structure. Remove CSS rule preventing text wrapping. Add comprehensive test coverage for verse wrapping behavior.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,5 @@ storybook-static
!.yarn/sdks
!.yarn/versions
.yarn/install-state.gz

.opencode/plans
119 changes: 119 additions & 0 deletions packages/ui/src/components/verse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div class="p">
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>In the beginning was the Word.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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 = `
<div class="p">
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>First verse.
<span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Second verse.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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 = `
<div class="p">
<span class="yv-v" v="39"></span><span class="yv-vlbl">39</span>"Come," he replied.
</div>
<div class="p">So they went and saw where he was staying.</div>
<div class="p">
<span class="yv-v" v="40"></span><span class="yv-vlbl">40</span>Andrew was one of the two.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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 = `
<div class="p">
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>First verse text.
<span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Second verse text.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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 = `
<div class="p">
<span class="yv-v" v="5"></span><span class="yv-vlbl">5</span>The light shines.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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 = `
<div class="p">
<span class="yv-v" v="42"></span><span class="yv-vlbl">42</span>And he brought him to Jesus.
</div>
<div class="s1 yv-h">Jesus Calls Philip</div>
<div class="p">
<span class="yv-v" v="43"></span><span class="yv-vlbl">43</span>The next day Jesus decided to leave.
</div>
`;

const { container } = render(<Verse.Html html={html} />);

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(<Verse.Text number={1} text="In the beginning" />);
Expand Down
175 changes: 22 additions & 153 deletions packages/ui/src/components/verse.tsx
Original file line number Diff line number Diff line change
@@ -1,160 +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<string, VerseNotes>;
};

/**
* 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;
}

/**
* 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)
*
* 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 `<div class="p">` elements.
* We use a TreeWalker to flatten the DOM into document order, then use index ranges to define verses.
*
* @returns Modified HTML with footnotes removed and placeholders inserted, plus notes data for popovers
*/
function extractNotesFromHtml(html: string): ExtractedNotes {
if (typeof window === 'undefined') return { html, notes: {} };

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)
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 withNotes = verses.filter((v) => v.fns.length > 0);

const notes: Record<string, VerseNotes> = {};
withNotes.forEach((verse) => {
// Build plain-text verse content for popover, replacing footnotes with A/B/C markers
let text = '';
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;

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 += `<sup class="yv:text-muted-foreground">${LETTERS[noteIdx++] || noteIdx}</sup>`;
}
} 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 <p> 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;
}
}
});

footnotes.forEach((fn) => {
fn.remove();
});

return { html: doc.body.innerHTML, notes };
}

const VerseFootnoteButton = memo(function VerseFootnoteButton({
verseNum,
verseNotes,
Expand Down Expand Up @@ -272,28 +144,25 @@ function yvDomTransformer(html: string, extractNotes: boolean = false): Extracte
return { html, notes: {} };
}

let extractedNotes: Record<string, VerseNotes> = {};
let processedHtml = html;
// Parse and sanitize HTML
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, DOMPURIFY_CONFIG),
'text/html',
);

if (extractNotes) {
const result = extractNotesFromHtml(html);
processedHtml = result.html;
extractedNotes = result.notes;
} else {
processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
}
// Wrap verse content FIRST (enables simple footnote extraction)
wrapVerseContent(doc);

// Safely parse and modify HTML to add spaces to paragraph elements
const parser = new DOMParser();
const doc = parser.parseFromString(processedHtml, 'text/html');
// 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 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;
}
});

Expand Down
Loading