Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1c57836
feat(core): auto-transform Bible HTML in getPassage
cameronapak Apr 20, 2026
789c2a1
fix(core): address PR review feedback
cameronapak Apr 20, 2026
453b0d3
chore: add changeset for auto-transform Bible HTML
cameronapak Apr 20, 2026
aece62a
feat(core): add transform opt-out and CSS fallback for raw Bible HTML
cameronapak Apr 24, 2026
20c1599
chore: update changeset with transform opt-out and CSS fallback
cameronapak Apr 24, 2026
c1a61db
chore(core): replace linkedom with jsdom as optional peer dep
cameronapak May 4, 2026
753776e
Merge branch 'main' into transform-bible-html
cameronapak May 6, 2026
4494016
Merge branch 'main' into transform-bible-html
cameronapak May 8, 2026
36e2adb
Merge branch 'main' into transform-bible-html
cameronapak Jun 3, 2026
dd3cf74
Merge branch 'main' into transform-bible-html
cameronapak Jul 28, 2026
85a3979
Merge branch 'main' into transform-bible-html
cameronapak Jul 30, 2026
3fa2470
fix(core): restore linkedom for server/edge Bible HTML transform
cameronapak Aug 4, 2026
5f5b414
chore(core): bump jsdom devDep to 28.1.0
cameronapak Aug 4, 2026
e23e38b
Merge branch 'main' into transform-bible-html
cameronapak Aug 4, 2026
2ee2a9a
fix(core): smoke real transform path in workerd
cameronapak Aug 4, 2026
4e017f6
chore(core): remove workerd linkedom smoke testing
cameronapak Aug 4, 2026
bf1c466
fix(core): restore jsdom as optional peer for HTML transform
cameronapak Aug 4, 2026
ad51471
fix(core): keep jsdom out of client bundles and trust transform marke…
cameronapak Aug 4, 2026
6b20f2b
fix(ui): externalize jsdom so client builds do not inline it
cameronapak Aug 4, 2026
b5a73fd
Merge branch 'main' into transform-bible-html
jhampton Aug 4, 2026
7206abc
fix(core): trust the transform marker only when every root carries it
cameronapak Aug 4, 2026
747c905
feat(hooks): forward transform option through usePassage
cameronapak Aug 4, 2026
1948fac
Merge branch 'main' into transform-bible-html
jhampton Aug 5, 2026
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); `usePassage` accepts the same `transform` option and forwards it. Bible reader CSS now handles verse label spacing for untransformed HTML automatically.
75 changes: 67 additions & 8 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,45 @@ Foundation package providing pure TypeScript API clients for YouVersion services
- For React hooks wrapping these clients → see `packages/hooks/AGENTS.md`
- For pre-built UI components → see `packages/ui/AGENTS.md`

The public API is whatever `src/index.ts`, `src/browser.ts`, and `src/server.ts`
export. Read those rather than a list here. Browser CSS ships from `src/styles/`
and is exported via `./browser/styles/*`.
## STRUCTURE
```
schemas/ # Zod schemas for all data types (schema-first design)
styles/ # Browser CSS (exported via ./browser/styles/*)
fonts.css # Google Fonts import (Inter, Source Serif 4)
theme.css # --yv-* design tokens on :root, dark mode, scoped preflight
bible-reader.css # USFM/Bible typography for [data-slot='yv-bible-renderer']
index.css # Barrel: imports fonts + theme + bible-reader
client.ts # ApiClient - main HTTP client
bible.ts # BibleClient - Bible data operations
languages.ts # LanguagesClient - language data
highlights.ts # HighlightsClient - user highlights
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 jsdom)
browser.ts # Browser entry point
server.ts # Server entry point
index.ts # Main entry point (runtime-agnostic)
```

## PUBLIC API

### TypeScript (`@youversion/platform-core`)
- `ApiClient`: Main HTTP client with auth handling
- `BibleClient`: Fetch Bibles, chapters, verses, versions
- `LanguagesClient`: Get available languages
- `HighlightsClient`: Manage user highlights
- `SignInWithYouVersionPKCE()`: PKCE auth flow function
- `SessionStorage`, `MemoryStorage`: Storage strategies
- `transformBibleHtml`: Runtime-agnostic Bible HTML transformer (requires DOM adapters)
- `TransformBibleHtmlOptions`: Options for DOM parsing and serialization

### Browser CSS (`@youversion/platform-core/browser/styles/*`)
- `index.css`: All-in-one import (fonts + theme + bible-reader)
- `theme.css`: `--yv-*` design tokens on `:root` + dark mode (`[data-yv-theme='dark']`) + scoped preflight
- `bible-reader.css`: USFM typography for `[data-slot='yv-bible-renderer']` or `[data-yv-sdk-bible-reader]`
- `fonts.css`: Google Fonts import (Inter, Source Serif 4)

## DOs / DON'Ts

Expand All @@ -26,12 +62,35 @@ and is exported via `./browser/styles/*`.

Three entry points, deliberately separate:

- `@youversion/platform-core` → runtime-agnostic `transformBibleHtml`, requires DOM adapters
- `@youversion/platform-core/browser` → convenience wrapper using native `DOMParser`
- `@youversion/platform-core/server` → convenience wrapper using `linkedom`
- `@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 jsdom)

The split keeps the main export runtime-agnostic and keeps `linkedom` out of
browser bundles. New DOM-touching code follows the same pattern.
**Examples:**

```ts
// Runtime-agnostic (works anywhere with custom adapters)
import { transformBibleHtml } from '@youversion/platform-core';

const result = transformBibleHtml(html, {
parseHtml: (h) => new DOMParser().parseFromString(h, 'text/html'),
serializeHtml: (doc) => doc.body.innerHTML,
});

// Browser convenience (uses native DOMParser)
import { transformBibleHtml } from '@youversion/platform-core/browser';

const result = transformBibleHtml(html);

// 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. `package.json` also maps `"browser": { "jsdom": false }` so Vite/Rollup client builds stub jsdom even when the main entry's dynamic `import('jsdom')` is present (Node-only path; browsers use native `DOMParser`).

## ADDING A NEW ENDPOINT OR CLIENT

Expand Down
10 changes: 7 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": {
"jsdom": false
},
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -48,20 +51,21 @@
"devDependencies": {
"@internal/eslint-config": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@types/jsdom": "^28.0.1",
Comment thread
greptile-apps[bot] marked this conversation as resolved.
"@vitest/coverage-v8": "4.0.4",
"dotenv-cli": "7.4.2",
"eslint": "9.38.0",
"jsdom": "24.0.0",
"jsdom": "28.1.0",
"msw": "2.11.6",
"tsup": "8.5.0",
"typescript": "5.9.3",
"vitest": "4.0.4"
},
"peerDependencies": {
"linkedom": "^0.18.12"
"jsdom": "^24.0.0 || ^28.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
70 changes: 66 additions & 4 deletions packages/core/src/bible-html-transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,8 @@ describe('transformBibleHtml - sanitization', () => {
const result = transformBibleHtml(html, createAdapters());

expect(result.html).not.toContain('onclick');
expect(result.html).toContain('<p>');
expect(result.html).toContain('Click me');
// Tag-boundary match so we don't accept a false positive like `<pre`.
expect(result.html).toMatch(/<p(?:\s[^>]*)?>Click me<\/p>/);
});

it('should unwrap anchor tags (not in allowlist) preserving text', () => {
Expand All @@ -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 All @@ -352,7 +352,7 @@ describe('transformBibleHtml - sanitization', () => {
expect(result.html).toContain('class="p"');
expect(result.html).toContain('class="wj"');
expect(result.html).toContain('colspan="2"');
expect(result.html).toContain('<table>');
expect(result.html).toMatch(/<table(?:\s[^>]*)?>/);
});

it('should unwrap unknown custom elements preserving text', () => {
Expand Down Expand Up @@ -387,6 +387,68 @@ 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);
});

it('should not short-circuit on untrusted nested data-yv-transformed', () => {
const html =
'<div><span data-yv-transformed></span><div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text<span class="yv-n f"><span class="ft">A note</span></span>.</div></div>';
const result = transformBibleHtml(html, createAdapters());

expect(result.html).toContain('data-verse-footnote');
expect(result.html).toMatch(/^<div\b[^>]*\bdata-yv-transformed\b/);
});

it('should transform raw siblings when only the first top-level element is marked', () => {
const raw =
'<div class="p"><span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Second<span class="yv-n f"><span class="ft">Sibling note</span></span>.</div>';
const transformed = transformBibleHtml(
'<div><div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>First.</div></div>',
createAdapters(),
);

const result = transformBibleHtml(transformed.html + raw, createAdapters());

expect(result.html).toContain('data-verse-footnote');
expect(result.html).toContain('Sibling note');
});

it('should mark every top-level element so a multi-root fragment stays idempotent', () => {
const html =
'<div class="p"><span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>One.</div>' +
'<div class="p"><span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Two.</div>';
const first = transformBibleHtml(html, createAdapters());
const second = transformBibleHtml(first.html, createAdapters());

expect(first.html.match(/data-yv-transformed/g)).toHaveLength(2);
expect(second.html).toBe(first.html);
});
});

describe('transformBibleHtmlForBrowser - DOMParser fallback', () => {
it('should throw when DOMParser is unavailable', () => {
const original = globalThis.DOMParser;
Expand Down
Loading
Loading