Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/auto-transform-bible-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@youversion/platform-core": minor
"@youversion/platform-react-hooks": minor
"@youversion/platform-react-ui": minor
---

Auto-transform Bible HTML in `getPassage` — verse wrapping, footnote extraction, sanitization, and table fixes now happen automatically. Consumers no longer need to call `transformBibleHtml` manually. Uses native DOMParser in browser, dynamic `import('linkedom')` on server. Added `data-yv-transformed` idempotency marker so double-transforms are a no-op.
27 changes: 14 additions & 13 deletions packages/core/src/__tests__/bible.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,18 +507,16 @@ describe('BibleClient', () => {
});

describe('getPassage', () => {
it('should fetch a passage for a verse', async () => {
it('should fetch a passage for a verse and auto-transform HTML', async () => {
const passage = await bibleClient.getPassage(111, 'GEN.1.1');

const { success } = BiblePassageSchema.safeParse(passage);
expect(success).toBe(true);

expect(passage).toEqual({
id: 'GEN.1.1',
content:
'<div><div class="pi"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>In the beginning God created the heavens and the earth. </div></div>',
reference: 'Genesis 1:1',
});
expect(passage.id).toBe('GEN.1.1');
expect(passage.reference).toBe('Genesis 1:1');
expect(passage.content).toContain('data-yv-transformed');
expect(passage.content).toContain('In the beginning God created');
});

it('should fetch a passage for a chapter', async () => {
Expand All @@ -534,36 +532,39 @@ describe('BibleClient', () => {
it('should fetch a passage with html format by default', async () => {
const passage = await bibleClient.getPassage(111, 'GEN.1.1');

expect(passage.content).toContain('<div>');
expect(passage.content).toContain('<div');
expect(passage.content).toContain('data-yv-transformed');
});

it('should fetch a passage with text format', async () => {
it('should not transform text format', async () => {
const passage = await bibleClient.getPassage(111, 'GEN.1.1', 'text');

expect(passage.content).not.toContain('<div>');
expect(passage.content).not.toContain('data-yv-transformed');
});

it('should fetch a passage with include_headings', async () => {
const passage = await bibleClient.getPassage(111, 'ROM.1', 'html', true);

expect(passage.id).toBe('ROM.1');
expect(passage.content).toContain('yv-h');
expect(passage.content).not.toContain('yv-n');
expect(passage.content).not.toContain('data-verse-footnote');
});

it('should fetch a passage with include_notes', async () => {
it('should fetch a passage with include_notes and transform footnotes', async () => {
const passage = await bibleClient.getPassage(111, 'ROM.1', 'html', undefined, true);

expect(passage.id).toBe('ROM.1');
expect(passage.content).toContain('yv-n');
// Footnotes are transformed into data-verse-footnote anchors
expect(passage.content).toContain('data-verse-footnote');
expect(passage.content).not.toContain('yv-h');
});

it('should fetch a passage with both include_headings and include_notes', async () => {
const passage = await bibleClient.getPassage(111, 'ROM.1', 'html', true, true);

expect(passage.id).toBe('ROM.1');
expect(passage.content).toContain('yv-n');
expect(passage.content).toContain('data-verse-footnote');
expect(passage.content).toContain('yv-h');
});

Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/bible-html-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ describe('transformBibleHtml - sanitization', () => {
const result = transformBibleHtml(html, createAdapters());

expect(result.html).not.toContain('onclick');
expect(result.html).toContain('<p>');
expect(result.html).toContain('<p');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: if you're wondering why this tag is seemingly cut off, it's because the tags would contain a data attribute in this new PR, which would then make it where the tag is something like <p data-yv-attribute> versus <p> standalone

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this expect() call do regular expressions? we don't have pre tags yet, that I know of, but still... it'd be great to tighten this up if that's not too difficult.

expect(result.html).toContain('Click me');
});

Expand Down Expand Up @@ -336,7 +336,7 @@ describe('transformBibleHtml - sanitization', () => {
const result = transformBibleHtml(html, createAdapters());

expect(result.html).not.toContain('style');
expect(result.html).toContain('<div>');
expect(result.html).toContain('<div');
expect(result.html).toContain('text');
});

Expand Down Expand Up @@ -387,6 +387,34 @@ describe('transformBibleHtml - sanitization', () => {
});
});

describe('transformBibleHtml - idempotency', () => {
it('should add data-yv-transformed marker after transforming', () => {
const html =
'<div><div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text.</div></div>';
const result = transformBibleHtml(html, createAdapters());

expect(result.html).toContain('data-yv-transformed');
});

it('should short-circuit when HTML is already transformed', () => {
const html =
'<div><div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text.</div></div>';
const first = transformBibleHtml(html, createAdapters());
const second = transformBibleHtml(first.html, createAdapters());

expect(second.html).toBe(first.html);
});

it('should produce identical output when transformed twice (idempotent)', () => {
const html =
'<div><div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text<span class="yv-n f"><span class="ft">A note</span></span>.</div></div>';
const first = transformBibleHtml(html, createAdapters());
const second = transformBibleHtml(first.html, createAdapters());

expect(second.html).toBe(first.html);
});
});

describe('transformBibleHtmlForBrowser - DOMParser fallback', () => {
it('should throw when DOMParser is unavailable', () => {
const original = globalThis.DOMParser;
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/bible-html-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const NON_BREAKING_SPACE = '\u00A0';

const FOOTNOTE_KEY_ATTR = 'data-footnote-key';

const TRANSFORMED_ATTR = 'data-yv-transformed';

const NEEDS_SPACE_BEFORE = /^[^\s.,;:!?)}\]'"'»›]/;

const ALLOWED_TAGS = new Set([
Expand Down Expand Up @@ -339,6 +341,11 @@ export function transformBibleHtml(
const doc = options.parseHtml(html);

sanitizeBibleHtmlDocument(doc);

// Already transformed — skip structural transforms
if (doc.querySelector(`[${TRANSFORMED_ATTR}]`)) {
return { html: options.serializeHtml(doc) };
}
wrapVerseContent(doc);
assignFootnoteKeys(doc);

Expand All @@ -348,6 +355,10 @@ export function transformBibleHtml(
addNbspToVerseLabels(doc);
fixIrregularTables(doc);

// Mark as transformed for idempotency
const root = doc.body?.firstElementChild ?? doc.body;
root?.setAttribute(TRANSFORMED_ATTR, '');

const transformedHtml = options.serializeHtml(doc);
return { html: transformedHtml };
}
Expand Down
56 changes: 47 additions & 9 deletions packages/core/src/bible.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import type { ApiClient } from './client';
import { transformBibleHtml, type TransformBibleHtmlOptions } from './bible-html-transformer';
import { BibleVersionSchema } from './schemas';
import type {
BibleBook,
Expand All @@ -13,6 +14,33 @@ import type {
VOTD,
} from './types';

async function getHtmlAdapters(): Promise<TransformBibleHtmlOptions> {
if (typeof globalThis.DOMParser !== 'undefined') {
return {
parseHtml: (h) =>
new globalThis.DOMParser().parseFromString(h, 'text/html') as unknown as Document,
serializeHtml: (doc) => doc.body.innerHTML,
};
}
let linkedom;
try {
linkedom = await import('linkedom');
} catch {
throw new Error(
'Server-side HTML transformation requires "linkedom". ' +
'Install it as a dependency or pass format: "text" to skip transformation.',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be better if there was a supported way to get raw html (untransformed), for people who don't want to import linkedom or who (for whatever reason) want the original data. How about a new format option, "rawhtml" or something like that?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'm writing this here because this error path is not something a builder will probably be excited to be in. The fix would mostly be elsewhere.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... or add another parameter so that the format can stay "html". That feels like a better idea to me.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like what you're processing. I've added a new commit to have the escape hatch allowing users to intentionally seek raw html versus transformed: aece62a

This PR is ready for re-review and re-consideration @davidfedor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things:

  • I'm curious, why linkedom as opposed to a more widely supported library like jsdom? Is there any risk of supply chain pollution with the newer library?
  • Have the docs been updated to reflect the need for a third party dependency?
  • Can the dependency be added as an optional peer dependency so it shows up in install logs?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ask as someone who is doing RSC data loading, and will need to have this work server-side :)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Bryson (@arinthros)!

Based on your comment, I've moved from linkedom to jsdom.

The YV dev docs will need to be updated immediatley after this (I will do that)

The dep has been added as an optional peer dep, so it can show up in install logs.

Anything else blocking this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arinthros following up ^

);
}
return {
parseHtml: (h) =>
new linkedom.DOMParser().parseFromString(
`<html><body>${h}</body></html>`,
'text/html',
) as unknown as Document,
serializeHtml: (doc) => doc.body.innerHTML,
};
}

/**
* Client for interacting with Bible API endpoints.
*/
Expand Down Expand Up @@ -234,18 +262,18 @@ export class BibleClient {

/**
* Fetches a passage (range of verses) from the Bible using the passages endpoint.
* This is the new API format that returns HTML-formatted content.
*
* Note: The HTML returned from the API contains inline footnote content that should
* be transformed before rendering. Use `transformBibleHtml()` or
* `transformBibleHtmlForBrowser()` to clean up the HTML and extract footnotes.
* When format is "html" (the default), the returned content is automatically
* sanitized and transformed — verse content is wrapped for CSS targeting,
* footnotes are extracted into data attributes, and verse labels get
* non-breaking spaces. No manual call to `transformBibleHtml` is needed.
*
* @param versionId The version ID.
* @param usfm The USFM reference (e.g., "JHN.3.1-2", "GEN.1", "JHN.3.16").
* @param format The format to return ("html" or "text", default: "html").
* @param include_headings Whether to include headings in the content.
* @param include_notes Whether to include notes in the content.
* @returns The requested BiblePassage object with HTML content.
* @returns The requested BiblePassage object.
*
* @example
* ```ts
Expand All @@ -258,9 +286,8 @@ export class BibleClient {
* // Get an entire chapter
* const chapter = await bibleClient.getPassage(3034, "GEN.1");
*
* // Transform HTML before rendering
* const passage = await bibleClient.getPassage(3034, "JHN.3.16", "html", true, true);
* const transformed = transformBibleHtmlForBrowser(passage.content);
* // Get plain text (no transformation applied)
* const text = await bibleClient.getPassage(3034, "JHN.3.16", "text");
* ```
*/
async getPassage(
Expand All @@ -286,7 +313,18 @@ export class BibleClient {
if (include_notes !== undefined) {
params.include_notes = include_notes;
}
return this.client.get<BiblePassage>(`/v1/bibles/${versionId}/passages/${usfm}`, params);
const passage = await this.client.get<BiblePassage>(
`/v1/bibles/${versionId}/passages/${usfm}`,
params,
);

if (format === 'html') {
const adapters = await getHtmlAdapters();
const { html } = transformBibleHtml(passage.content, adapters);
return { ...passage, content: html };
}

return passage;
}

/**
Expand Down
5 changes: 2 additions & 3 deletions packages/ui/src/components/verse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,8 @@ export const Verse = {
}: VerseHtmlProps,
ref,
): ReactNode => {
// transformBibleHtml uses the browser's native DOMParser, which doesn't
// exist during SSR. Return raw html on the server; the client-side
// useLayoutEffect in BibleTextHtml will handle it after hydration.
// SSR safety: DOMParser doesn't exist during server render.
// Idempotent — already-transformed HTML from getPassage is a no-op.
const transformedHtml = useMemo(
() => (typeof window === 'undefined' ? html : transformBibleHtml(html).html),
[html],
Expand Down
Loading