From 1c57836ad81e770a5b77bb953dba91812c7f128d Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 20 Apr 2026 14:01:41 -0500 Subject: [PATCH 1/6] feat(core): auto-transform Bible HTML in getPassage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPassage now automatically sanitizes and transforms HTML content before returning — verse wrapping, footnote extraction, nbsp, and table fixes all happen at the root. Uses native DOMParser in browser, dynamic import('linkedom') on server. Added data-yv-transformed idempotency marker so double-transforms are a no-op. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/__tests__/bible.test.ts | 27 ++++++----- .../core/src/bible-html-transformer.test.ts | 32 ++++++++++++- packages/core/src/bible-html-transformer.ts | 11 +++++ packages/core/src/bible.ts | 48 +++++++++++++++---- packages/ui/src/components/verse.tsx | 5 +- 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/bible.test.ts b/packages/core/src/__tests__/bible.test.ts index 7f3b961f..82aa0e0e 100644 --- a/packages/core/src/__tests__/bible.test.ts +++ b/packages/core/src/__tests__/bible.test.ts @@ -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: - '
1In the beginning God created the heavens and the earth.
', - 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 () => { @@ -534,13 +532,15 @@ 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('
'); + expect(passage.content).toContain(' { + it('should not transform text format', async () => { const passage = await bibleClient.getPassage(111, 'GEN.1.1', 'text'); expect(passage.content).not.toContain('
'); + expect(passage.content).not.toContain('data-yv-transformed'); }); it('should fetch a passage with include_headings', async () => { @@ -548,14 +548,15 @@ describe('BibleClient', () => { 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'); }); @@ -563,7 +564,7 @@ describe('BibleClient', () => { 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'); }); diff --git a/packages/core/src/bible-html-transformer.test.ts b/packages/core/src/bible-html-transformer.test.ts index fee794b1..8f1cd97b 100644 --- a/packages/core/src/bible-html-transformer.test.ts +++ b/packages/core/src/bible-html-transformer.test.ts @@ -308,7 +308,7 @@ describe('transformBibleHtml - sanitization', () => { const result = transformBibleHtml(html, createAdapters()); expect(result.html).not.toContain('onclick'); - expect(result.html).toContain('

'); + expect(result.html).toContain(' { const result = transformBibleHtml(html, createAdapters()); expect(result.html).not.toContain('style'); - expect(result.html).toContain('

'); + expect(result.html).toContain(' { }); }); +describe('transformBibleHtml - idempotency', () => { + it('should add data-yv-transformed marker after transforming', () => { + const html = + '
1Text.
'; + const result = transformBibleHtml(html, createAdapters()); + + expect(result.html).toContain('data-yv-transformed'); + }); + + it('should short-circuit when HTML is already transformed', () => { + const html = + '
1Text.
'; + 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 = + '
1Verse textA note.
'; + 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; diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts index 448a26cc..e7109979 100644 --- a/packages/core/src/bible-html-transformer.ts +++ b/packages/core/src/bible-html-transformer.ts @@ -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([ @@ -338,6 +340,11 @@ export function transformBibleHtml( ): TransformedBibleHtml { const doc = options.parseHtml(html); + // Already transformed — return as-is + if (doc.querySelector(`[${TRANSFORMED_ATTR}]`)) { + return { html: options.serializeHtml(doc) }; + } + sanitizeBibleHtmlDocument(doc); wrapVerseContent(doc); assignFootnoteKeys(doc); @@ -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 }; } diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 918fdf3a..8cb958e1 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -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, @@ -13,6 +14,25 @@ import type { VOTD, } from './types'; +async function getHtmlAdapters(): Promise { + if (typeof globalThis.DOMParser !== 'undefined') { + return { + parseHtml: (h) => + new globalThis.DOMParser().parseFromString(h, 'text/html') as unknown as Document, + serializeHtml: (doc) => doc.body.innerHTML, + }; + } + const { DOMParser } = await import('linkedom'); + return { + parseHtml: (h) => + new DOMParser().parseFromString( + `${h}`, + 'text/html', + ) as unknown as Document, + serializeHtml: (doc) => doc.body.innerHTML, + }; +} + /** * Client for interacting with Bible API endpoints. */ @@ -234,18 +254,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 @@ -258,9 +278,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( @@ -286,7 +305,18 @@ export class BibleClient { if (include_notes !== undefined) { params.include_notes = include_notes; } - return this.client.get(`/v1/bibles/${versionId}/passages/${usfm}`, params); + const passage = await this.client.get( + `/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; } /** diff --git a/packages/ui/src/components/verse.tsx b/packages/ui/src/components/verse.tsx index 93a3186d..8ad73950 100644 --- a/packages/ui/src/components/verse.tsx +++ b/packages/ui/src/components/verse.tsx @@ -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], From 789c2a1bd97468075851d4e1a8379cabebb5a2b3 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 20 Apr 2026 14:10:10 -0500 Subject: [PATCH 2/6] fix(core): address PR review feedback Run XSS sanitization before idempotency check so data-yv-transformed cannot bypass sanitizeBibleHtmlDocument. Add clear error message when linkedom is missing on server instead of opaque module-not-found error. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/bible-html-transformer.ts | 6 +++--- packages/core/src/bible.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts index e7109979..be1d4c83 100644 --- a/packages/core/src/bible-html-transformer.ts +++ b/packages/core/src/bible-html-transformer.ts @@ -340,12 +340,12 @@ export function transformBibleHtml( ): TransformedBibleHtml { const doc = options.parseHtml(html); - // Already transformed — return as-is + sanitizeBibleHtmlDocument(doc); + + // Already transformed — skip structural transforms if (doc.querySelector(`[${TRANSFORMED_ATTR}]`)) { return { html: options.serializeHtml(doc) }; } - - sanitizeBibleHtmlDocument(doc); wrapVerseContent(doc); assignFootnoteKeys(doc); diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 8cb958e1..e813f7fc 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -22,10 +22,18 @@ async function getHtmlAdapters(): Promise { serializeHtml: (doc) => doc.body.innerHTML, }; } - const { DOMParser } = await import('linkedom'); + 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.', + ); + } return { parseHtml: (h) => - new DOMParser().parseFromString( + new linkedom.DOMParser().parseFromString( `${h}`, 'text/html', ) as unknown as Document, From 453b0d3fbda4a7ae7dc7ea6acc0e83fb5fa5c804 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 20 Apr 2026 14:11:20 -0500 Subject: [PATCH 3/6] chore: add changeset for auto-transform Bible HTML Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/auto-transform-bible-html.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/auto-transform-bible-html.md diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md new file mode 100644 index 00000000..7987b4f8 --- /dev/null +++ b/.changeset/auto-transform-bible-html.md @@ -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. From aece62a6ad8ebccbc376c0d7b47093f360827a28 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 24 Apr 2026 11:23:56 -0500 Subject: [PATCH 4/6] feat(core): add transform opt-out and CSS fallback for raw Bible HTML Add `transform` param to `getPassage` (default: true) so consumers can receive untransformed HTML without needing linkedom on the server. CSS now handles verse label spacing for raw HTML via ::after pseudo-element. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/__tests__/bible.test.ts | 14 ++++++++++++++ packages/core/src/bible.ts | 11 ++++++++++- packages/core/src/styles/bible-reader.css | 6 ++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/bible.test.ts b/packages/core/src/__tests__/bible.test.ts index 82aa0e0e..38834f0f 100644 --- a/packages/core/src/__tests__/bible.test.ts +++ b/packages/core/src/__tests__/bible.test.ts @@ -543,6 +543,20 @@ describe('BibleClient', () => { 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(' { const passage = await bibleClient.getPassage(111, 'ROM.1', 'html', true); diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index e813f7fc..76266cd3 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -273,6 +273,11 @@ export class BibleClient { * @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. + * @param transform Whether to auto-transform HTML content (default: `true`). + * Set to `false` to receive the original, untransformed HTML from the API. + * Raw HTML is sufficient for simple display (e.g., verse-of-the-day) where + * verse-level interactivity like highlighting or footnote popovers isn't + * needed. Also avoids the `linkedom` dependency on the server. * @returns The requested BiblePassage object. * * @example @@ -288,6 +293,9 @@ export class BibleClient { * * // Get plain text (no transformation applied) * const text = await bibleClient.getPassage(3034, "JHN.3.16", "text"); + * + * // Get raw, untransformed HTML (no linkedom needed on server) + * const raw = await bibleClient.getPassage(3034, "JHN.3.16", "html", undefined, undefined, false); * ``` */ async getPassage( @@ -296,6 +304,7 @@ export class BibleClient { format: 'html' | 'text' = 'html', include_headings?: boolean, include_notes?: boolean, + transform?: boolean, ): Promise { BibleClient.versionIdSchema.parse(versionId); if (include_headings !== undefined) { @@ -318,7 +327,7 @@ export class BibleClient { params, ); - if (format === 'html') { + if (format === 'html' && transform !== false) { const adapters = await getHtmlAdapters(); const { html } = transformBibleHtml(passage.content, adapters); return { ...passage, content: html }; diff --git a/packages/core/src/styles/bible-reader.css b/packages/core/src/styles/bible-reader.css index 870a662d..54459916 100644 --- a/packages/core/src/styles/bible-reader.css +++ b/packages/core/src/styles/bible-reader.css @@ -90,6 +90,12 @@ font-family: var(--yv-font-sans); } + /* When content hasn't been JS-transformed, add spacing after verse labels via CSS. + Transformed HTML already has a \u00A0 inserted by addNbspToVerseLabels(). */ + &:not(:has([data-yv-transformed])) .yv-vlbl::after { + content: "\00A0"; + } + /* \f - Footnote container (YouVersion wrapper) */ & .yv-n { display: none; From 20c1599366b9abd3f4b3a8e81b319389211829a8 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 24 Apr 2026 11:25:05 -0500 Subject: [PATCH 5/6] chore: update changeset with transform opt-out and CSS fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/auto-transform-bible-html.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md index 7987b4f8..7a2aebea 100644 --- a/.changeset/auto-transform-bible-html.md +++ b/.changeset/auto-transform-bible-html.md @@ -4,4 +4,4 @@ "@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. +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. Pass `transform: false` to receive raw, untransformed HTML (useful for simple display or when `linkedom` is unavailable). Bible reader CSS now handles verse label spacing for untransformed HTML automatically. From c1a61dbfd2c0cdf36d8f43727d0c8a7b7b3a2512 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 4 May 2026 09:33:34 -0500 Subject: [PATCH 6/6] chore(core): replace linkedom with jsdom as optional peer dep Amp-Thread-ID: https://ampcode.com/threads/T-019df363-8170-750b-866b-d30055111f9b Co-authored-by: Amp --- .changeset/auto-transform-bible-html.md | 2 +- packages/core/AGENTS.md | 8 +- packages/core/package.json | 5 +- .../core/src/bible-html-transformer-server.ts | 14 +- .../src/bible-html-transformer.server.test.ts | 10 +- packages/core/src/bible.ts | 18 +-- pnpm-lock.yaml | 140 +++--------------- 7 files changed, 48 insertions(+), 149 deletions(-) diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md index 7a2aebea..f906d672 100644 --- a/.changeset/auto-transform-bible-html.md +++ b/.changeset/auto-transform-bible-html.md @@ -4,4 +4,4 @@ "@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. Pass `transform: false` to receive raw, untransformed HTML (useful for simple display or when `linkedom` is unavailable). Bible reader CSS now handles verse label spacing for untransformed HTML automatically. +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. diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 0278a742..493cc042 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -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) @@ -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:** @@ -82,7 +82,7 @@ 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); @@ -90,7 +90,7 @@ 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 diff --git a/packages/core/package.json b/packages/core/package.json index cd59c9e1..a5c1f179 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -57,10 +58,10 @@ "vitest": "4.0.4" }, "peerDependencies": { - "linkedom": "^0.18.12" + "jsdom": "^24.0.0" }, "peerDependenciesMeta": { - "linkedom": { + "jsdom": { "optional": true } }, diff --git a/packages/core/src/bible-html-transformer-server.ts b/packages/core/src/bible-html-transformer-server.ts index e7e25911..6b42819c 100644 --- a/packages/core/src/bible-html-transformer-server.ts +++ b/packages/core/src/bible-html-transformer-server.ts @@ -1,4 +1,4 @@ -import { DOMParser } from 'linkedom'; +import { JSDOM } from 'jsdom'; import { transformBibleHtml as transformBibleHtmlWithAdapters, @@ -6,14 +6,11 @@ import { } 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 * @@ -28,10 +25,7 @@ import { export function transformBibleHtml(html: string): TransformedBibleHtml { return transformBibleHtmlWithAdapters(html, { parseHtml: (h: string) => - new DOMParser().parseFromString( - `${h}`, - 'text/html', - ) as unknown as Document, + new JSDOM(`${h}`).window.document, serializeHtml: (doc: Document) => doc.body.innerHTML, }); } diff --git a/packages/core/src/bible-html-transformer.server.test.ts b/packages/core/src/bible-html-transformer.server.test.ts index 3d4dc69a..1df9f0b6 100644 --- a/packages/core/src/bible-html-transformer.server.test.ts +++ b/packages/core/src/bible-html-transformer.server.test.ts @@ -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 = `
@@ -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"'); @@ -77,8 +77,8 @@ describe('transformBibleHtml', () => { const result = transformBibleHtml(html); - // linkedom encodes non-breaking space as   instead of the raw character - expect(result.html).toMatch(/1(\u00A0| )/); + // jsdom may encode non-breaking space as   instead of the raw character + expect(result.html).toMatch(/1(\u00A0| | )/); }); it('should handle intro chapter footnotes', () => { @@ -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 = `
Jesus said diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 76266cd3..5fa128c1 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -22,21 +22,19 @@ async function getHtmlAdapters(): Promise { serializeHtml: (doc) => doc.body.innerHTML, }; } - let linkedom; + let jsdom; try { - linkedom = await import('linkedom'); + jsdom = await import('jsdom'); } catch { throw new Error( - 'Server-side HTML transformation requires "linkedom". ' + - 'Install it as a dependency or pass format: "text" to skip transformation.', + 'Server-side HTML transformation requires "jsdom". ' + + 'Install it as a dependency or pass transform: false to skip transformation.', ); } return { parseHtml: (h) => - new linkedom.DOMParser().parseFromString( - `${h}`, - 'text/html', - ) as unknown as Document, + new jsdom.JSDOM(`${h}`).window + .document as unknown as Document, serializeHtml: (doc) => doc.body.innerHTML, }; } @@ -277,7 +275,7 @@ export class BibleClient { * Set to `false` to receive the original, untransformed HTML from the API. * Raw HTML is sufficient for simple display (e.g., verse-of-the-day) where * verse-level interactivity like highlighting or footnote popovers isn't - * needed. Also avoids the `linkedom` dependency on the server. + * needed. Also avoids the `jsdom` dependency on the server. * @returns The requested BiblePassage object. * * @example @@ -294,7 +292,7 @@ export class BibleClient { * // Get plain text (no transformation applied) * const text = await bibleClient.getPassage(3034, "JHN.3.16", "text"); * - * // Get raw, untransformed HTML (no linkedom needed on server) + * // Get raw, untransformed HTML (no jsdom needed on server) * const raw = await bibleClient.getPassage(3034, "JHN.3.16", "html", undefined, undefined, false); * ``` */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4a480ea..5bc4ffb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,9 +136,6 @@ importers: packages/core: dependencies: - linkedom: - specifier: ^0.18.12 - version: 0.18.12 zod: specifier: 4.1.12 version: 4.1.12 @@ -149,6 +146,9 @@ importers: '@internal/tsconfig': specifier: workspace:* version: link:../../tools/tsconfig + '@types/jsdom': + specifier: ^28.0.1 + version: 28.0.1 '@vitest/coverage-v8': specifier: 4.0.4 version: 4.0.4(@vitest/browser@4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(vite@7.1.11(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1))(vitest@4.0.4(@types/node@24.11.0)(@vitest/browser-playwright@4.0.4)(jiti@2.6.1)(jsdom@24.0.0)(lightningcss@1.31.1)(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(terser@5.44.0)(yaml@2.8.1)))(vitest@4.0.4(@types/node@24.11.0)(@vitest/browser-playwright@4.0.4)(jiti@2.6.1)(jsdom@24.0.0)(lightningcss@1.31.1)(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(terser@5.44.0)(yaml@2.8.1)) @@ -2833,6 +2833,9 @@ packages: '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@types/jsdom@28.0.1': + resolution: {integrity: sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2862,6 +2865,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -3259,9 +3265,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3481,17 +3484,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -3500,9 +3496,6 @@ packages: engines: {node: '>=4'} hasBin: true - cssom@0.5.0: - resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -3648,19 +3641,6 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dotenv-cli@7.4.2: resolution: {integrity: sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==} hasBin: true @@ -3730,18 +3710,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4252,15 +4224,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-escaper@3.0.3: - resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -4830,15 +4796,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkedom@0.18.12: - resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} - engines: {node: '>=16'} - peerDependencies: - canvas: '>= 2' - peerDependenciesMeta: - canvas: - optional: true - lint-staged@16.2.5: resolution: {integrity: sha512-o36wH3OX0jRWqDw5dOa8a8x6GXTKaLM+LvhRaucZxez0IxA+KNDUCiyjBfNgsMNmchwSX6urLSL7wShcUqAang==} engines: {node: '>=20.17'} @@ -5106,9 +5063,6 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} @@ -6170,9 +6124,6 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - uhyphen@0.2.0: - resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -6180,6 +6131,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.25.0: + resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -9127,6 +9081,13 @@ snapshots: '@types/istanbul-lib-coverage@2.0.6': {} + '@types/jsdom@28.0.1': + dependencies: + '@types/node': 24.11.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.25.0 + '@types/json-schema@7.0.15': {} '@types/mdx@2.0.13': {} @@ -9153,6 +9114,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/tough-cookie@4.0.5': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)': @@ -9778,8 +9741,6 @@ snapshots: transitivePeerDependencies: - supports-color - boolbase@1.0.0: {} - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -9977,27 +9938,15 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 - css-what@6.2.2: {} - css.escape@1.5.1: {} cssesc@3.0.0: {} - cssom@0.5.0: {} - cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -10114,24 +10063,6 @@ snapshots: dom-accessibility-api@0.6.3: {} - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - dotenv-cli@7.4.2: dependencies: cross-spawn: 7.0.6 @@ -10193,12 +10124,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - entities@4.5.0: {} - entities@6.0.1: {} - entities@7.0.1: {} - env-paths@2.2.1: {} environment@1.1.0: {} @@ -10994,19 +10921,10 @@ snapshots: html-escaper@2.0.2: {} - html-escaper@3.0.3: {} - html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -11547,14 +11465,6 @@ snapshots: lines-and-columns@1.2.4: {} - linkedom@0.18.12: - dependencies: - css-select: 5.2.2 - cssom: 0.5.0 - html-escaper: 3.0.3 - htmlparser2: 10.1.0 - uhyphen: 0.2.0 - lint-staged@16.2.5: dependencies: commander: 14.0.1 @@ -11800,10 +11710,6 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - nwsapi@2.2.22: {} object-assign@4.1.1: {} @@ -13064,8 +12970,6 @@ snapshots: ufo@1.6.1: {} - uhyphen@0.2.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -13075,6 +12979,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.25.0: {} + unicorn-magic@0.3.0: {} universalify@0.1.2: {}