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/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('jsdom')` on server. `jsdom` is now declared as an optional peer dependency so install logs surface it for server consumers. Added `data-yv-transformed` idempotency marker so double-transforms are a no-op. Pass `transform: false` to receive raw, untransformed HTML (useful for simple display or when `jsdom` is unavailable). Bible reader CSS now handles verse label spacing for untransformed HTML automatically.
8 changes: 4 additions & 4 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ YouVersionAPI.ts # Base YouVersion API client
SignInWithYouVersionPKCE.ts # PKCE auth implementation
StorageStrategy.ts # Storage interface (SessionStorage, MemoryStorage)
bible-html-transformer.ts # Runtime-agnostic transformer (also contains browser convenience fn)
bible-html-transformer-server.ts # Server convenience wrapper (uses linkedom)
bible-html-transformer-server.ts # Server convenience wrapper (uses jsdom)
browser.ts # Browser entry point
server.ts # Server entry point
index.ts # Main entry point (runtime-agnostic)
Expand Down Expand Up @@ -64,7 +64,7 @@ The Bible HTML transformer provides both a runtime-agnostic core and environment

- `@youversion/platform-core` → Runtime-agnostic `transformBibleHtml` (requires DOM adapters)
- `@youversion/platform-core/browser` → Browser convenience wrapper (uses native DOMParser)
- `@youversion/platform-core/server` → Server convenience wrapper (uses linkedom)
- `@youversion/platform-core/server` → Server convenience wrapper (uses jsdom)

**Examples:**

Expand All @@ -82,15 +82,15 @@ import { transformBibleHtml } from '@youversion/platform-core/browser';

const result = transformBibleHtml(html);

// Server convenience (uses linkedom, requires: npm install linkedom)
// Server convenience (uses jsdom, requires: npm install jsdom)
import { transformBibleHtml } from '@youversion/platform-core/server';

const result = transformBibleHtml(html);
```

**Why separate entry points?**

This architecture keeps the main export truly runtime-agnostic while providing ergonomic convenience wrappers for common environments. The separate `/browser` and `/server` entry points ensure optimal bundle sizes - linkedom won't be bundled in browser builds.
This architecture keeps the main export truly runtime-agnostic while providing ergonomic convenience wrappers for common environments. The separate `/browser` and `/server` entry points ensure optimal bundle sizes - jsdom won't be bundled in browser builds.

## ADDING A NEW ENDPOINT OR CLIENT

Expand Down
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"devDependencies": {
"@internal/eslint-config": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@types/jsdom": "^28.0.1",
"@vitest/coverage-v8": "4.0.4",
"dotenv-cli": "7.4.2",
"eslint": "9.38.0",
Expand All @@ -57,10 +58,10 @@
"vitest": "4.0.4"
},
"peerDependencies": {
"linkedom": "^0.18.12"
"jsdom": "^24.0.0"
},
"peerDependenciesMeta": {
"linkedom": {
"jsdom": {
"optional": true
}
},
Expand Down
41 changes: 28 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,53 @@ 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 skip transformation when transform is false', async () => {
const passage = await bibleClient.getPassage(
111,
'GEN.1.1',
'html',
undefined,
undefined,
false,
);

expect(passage.content).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
14 changes: 4 additions & 10 deletions packages/core/src/bible-html-transformer-server.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { DOMParser } from 'linkedom';
import { JSDOM } from 'jsdom';

import {
transformBibleHtml as transformBibleHtmlWithAdapters,
type TransformedBibleHtml,
} from './bible-html-transformer';

/**
* Transforms Bible HTML for server environments using linkedom.
* Transforms Bible HTML for server environments using jsdom.
*
* Import from `@youversion/platform-core/server` to avoid bundling linkedom
* Import from `@youversion/platform-core/server` to avoid bundling jsdom
* in client-side builds.
*
* linkedom requires HTML to be wrapped in body tags for `doc.body.innerHTML`
* to work correctly, so this function handles that wrapping automatically.
*
* @param html - The raw Bible HTML from the YouVersion API
* @returns The transformed HTML
*
Expand All @@ -28,10 +25,7 @@ import {
export function transformBibleHtml(html: string): TransformedBibleHtml {
return transformBibleHtmlWithAdapters(html, {
parseHtml: (h: string) =>
new DOMParser().parseFromString(
`<html><body>${h}</body></html>`,
'text/html',
) as unknown as Document,
new JSDOM(`<!DOCTYPE html><html><body>${h}</body></html>`).window.document,
serializeHtml: (doc: Document) => doc.body.innerHTML,
});
}
10 changes: 5 additions & 5 deletions packages/core/src/bible-html-transformer.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest';
import { transformBibleHtml } from './bible-html-transformer-server';

describe('transformBibleHtml', () => {
it('should transform HTML using linkedom', () => {
it('should transform HTML using jsdom', () => {
const html = `
<div>
<div class="p">
Expand Down Expand Up @@ -58,7 +58,7 @@ describe('transformBibleHtml', () => {

const result = transformBibleHtml(html);

// linkedom may serialize attributes in different order than browsers
// jsdom may serialize attributes in different order than browsers
expect(result.html).toContain('class="yv-v"');
expect(result.html).toContain('v="1"');
expect(result.html).toContain('v="2"');
Expand All @@ -77,8 +77,8 @@ describe('transformBibleHtml', () => {

const result = transformBibleHtml(html);

// linkedom encodes non-breaking space as &#160; instead of the raw character
expect(result.html).toMatch(/1(\u00A0|&#160;)/);
// jsdom may encode non-breaking space as &nbsp; instead of the raw character
expect(result.html).toMatch(/1(\u00A0|&#160;|&nbsp;)/);
});

it('should handle intro chapter footnotes', () => {
Expand Down Expand Up @@ -137,7 +137,7 @@ describe('transformBibleHtml', () => {
expect(result.html).toContain('Click me');
});

it('should preserve safe Bible HTML through linkedom', () => {
it('should preserve safe Bible HTML through jsdom', () => {
const html = `
<div class="p">
<span class="wj">Jesus said</span>
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
Loading
Loading