From 1c57836ad81e770a5b77bb953dba91812c7f128d Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 20 Apr 2026 14:01:41 -0500 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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: {} From 3fa2470245af3b735bad2ec1714aadc2949429ba Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 08:38:45 -0500 Subject: [PATCH 07/15] fix(core): restore linkedom for server/edge Bible HTML transform jsdom fails at runtime on Cloudflare Workers after a clean bundle. linkedom covers the transform DOM surface, runs in workerd, and keeps the optional peer small. Consolidate adapters and add a workerd CI smoke so the next peer swap cannot silently break edge. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- .changeset/auto-transform-bible-html.md | 2 +- .github/workflows/ci.yml | 22 +++ .gitignore | 1 + packages/core/AGENTS.md | 11 +- packages/core/package.json | 8 +- .../core/scripts/workerd-linkedom-smoke.mjs | 96 ++++++++++ .../core/scripts/workerd-smoke/worker.mjs | 37 ++++ .../core/scripts/workerd-smoke/wrangler.toml | 4 + packages/core/src/bible-html-adapters.ts | 62 ++++++ .../core/src/bible-html-transformer-server.ts | 16 +- .../src/bible-html-transformer.server.test.ts | 32 +++- packages/core/src/bible-html-transformer.ts | 11 +- packages/core/src/bible.ts | 34 +--- pnpm-lock.yaml | 180 +++++++++++++++++- 14 files changed, 449 insertions(+), 67 deletions(-) create mode 100644 packages/core/scripts/workerd-linkedom-smoke.mjs create mode 100644 packages/core/scripts/workerd-smoke/worker.mjs create mode 100644 packages/core/scripts/workerd-smoke/wrangler.toml create mode 100644 packages/core/src/bible-html-adapters.ts diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md index f906d672..5e42e573 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('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. +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 the browser and dynamic `import('linkedom')` on server/edge runtimes (including Cloudflare Workers). `linkedom` is 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 `linkedom` is unavailable). Bible reader CSS now handles verse label spacing for untransformed HTML automatically. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf19b98b..54e342be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,28 @@ jobs: git diff --staged --quiet || git commit -m "chore: update coverage badges" git push + workerd-linkedom: + name: Workerd linkedom smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run linkedom inside workerd + run: pnpm --filter @youversion/platform-core test:workerd + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6bedda97..847e22e6 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ storybook-static .opencode/ .superset/ .codex/ +packages/core/scripts/workerd-smoke/.wrangler/ diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 493cc042..47682373 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -23,7 +23,8 @@ 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) +bible-html-adapters.ts # Shared DOMParser / linkedom adapter factories +bible-html-transformer-server.ts # Server convenience wrapper (uses linkedom) browser.ts # Browser entry point server.ts # Server entry point index.ts # Main entry point (runtime-agnostic) @@ -64,7 +65,9 @@ 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 jsdom) +- `@youversion/platform-core/server` → Server / edge convenience wrapper (uses linkedom) + +`getPassage` auto-transform uses native `DOMParser` when present, otherwise dynamic `import('linkedom')`. That path runs on Node and Cloudflare Workers. Pass `transform: false` to skip transformation (and the linkedom peer). **Examples:** @@ -82,7 +85,7 @@ import { transformBibleHtml } from '@youversion/platform-core/browser'; const result = transformBibleHtml(html); -// Server convenience (uses jsdom, requires: npm install jsdom) +// Server / edge convenience (uses linkedom, requires: npm install linkedom) import { transformBibleHtml } from '@youversion/platform-core/server'; const result = transformBibleHtml(html); @@ -90,7 +93,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 - jsdom 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 — linkedom 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 cc1ef885..deb2f17c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,8 @@ "test": "dotenv -e .env.local -- vitest run", "test:integration": "INTEGRATION_TESTS=true dotenv -e .env.local -- vitest run", "test:watch": "dotenv -e .env.local -- vitest", - "test:coverage": "dotenv -e .env.local -- vitest run --coverage" + "test:coverage": "dotenv -e .env.local -- vitest run --coverage", + "test:workerd": "node ./scripts/workerd-linkedom-smoke.mjs" }, "devDependencies": { "@internal/eslint-config": "workspace:*", @@ -53,16 +54,17 @@ "dotenv-cli": "7.4.2", "eslint": "9.38.0", "jsdom": "24.0.0", + "linkedom": "0.18.13", "msw": "2.11.6", "tsup": "8.5.0", "typescript": "5.9.3", "vitest": "4.0.4" }, "peerDependencies": { - "jsdom": "^24.0.0" + "linkedom": ">=0.18.12" }, "peerDependenciesMeta": { - "jsdom": { + "linkedom": { "optional": true } }, diff --git a/packages/core/scripts/workerd-linkedom-smoke.mjs b/packages/core/scripts/workerd-linkedom-smoke.mjs new file mode 100644 index 00000000..60c45abb --- /dev/null +++ b/packages/core/scripts/workerd-linkedom-smoke.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * Spawns `wrangler dev --local` against the linkedom workerd smoke worker, + * hits it once, and exits non-zero on failure. + * + * Why this exists: jsdom/happy-dom can bundle for Workers and still die on the + * first request. A dry-run deploy would miss that. This runs the real runtime. + */ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const smokeDir = path.join(__dirname, 'workerd-smoke'); +const require = createRequire(import.meta.url); + +// Resolve linkedom from packages/core so wrangler's bundler can find it. +try { + require.resolve('linkedom'); +} catch { + console.error('linkedom is not installed in @youversion/platform-core'); + process.exit(1); +} + +const port = 8787 + Math.floor(Math.random() * 1000); + +const child = spawn( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + [ + 'dlx', + 'wrangler@4.118.0', + 'dev', + '--local', + '--port', + String(port), + '--config', + path.join(smokeDir, 'wrangler.toml'), + ], + { + cwd: path.join(__dirname, '..'), + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, FORCE_COLOR: '0' }, + }, +); + +let output = ''; +const onData = (buf) => { + output += buf.toString(); +}; + +child.stdout.on('data', onData); +child.stderr.on('data', onData); + +const ready = + /Ready on|Local:|http:\/\/127\.0\.0\.1:|http:\/\/localhost:/i; + +const deadline = Date.now() + 90_000; + +async function waitForReady() { + while (Date.now() < deadline) { + if (ready.test(output) || output.includes(`127.0.0.1:${port}`)) { + return; + } + if (child.exitCode !== null) { + throw new Error(`wrangler exited early (${child.exitCode})\n${output}`); + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`Timed out waiting for wrangler\n${output}`); +} + +try { + await waitForReady(); + const res = await fetch(`http://127.0.0.1:${port}/`); + const body = await res.text(); + if (!res.ok || !body.startsWith('OK')) { + throw new Error(`Smoke failed: HTTP ${res.status} body=${body}\n${output}`); + } + console.log(body); + process.exitCode = 0; +} catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; +} finally { + child.kill('SIGTERM'); + // Ensure the process tree does not hang CI. + setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + process.exit(process.exitCode ?? 1); + }, 2000).unref(); +} diff --git a/packages/core/scripts/workerd-smoke/worker.mjs b/packages/core/scripts/workerd-smoke/worker.mjs new file mode 100644 index 00000000..8edbea59 --- /dev/null +++ b/packages/core/scripts/workerd-smoke/worker.mjs @@ -0,0 +1,37 @@ +import { DOMParser } from 'linkedom'; + +/** + * Minimal edge smoke: linkedom must parse + query in workerd. + * Catches the class of failure where a DOM lib bundles cleanly then dies at runtime. + */ +export default { + async fetch() { + const doc = new DOMParser().parseFromString( + ` +
+ 1Hi + note. +
+
a
bc
+ `, + 'text/html', + ); + + const marks = doc.querySelectorAll('.yv-v[v]').length; + const footnote = doc.querySelector('.yv-n.f'); + const closest = footnote?.closest('.p, p, div.p'); + const rows = doc.querySelectorAll('table tr').length; + const replaced = doc.createElement('span'); + replaced.setAttribute('data-ok', '1'); + footnote?.replaceWith(replaced); + + if (marks !== 1 || !closest || rows < 2 || !doc.querySelector('[data-ok="1"]')) { + return new Response( + `FAIL marks=${marks} closest=${Boolean(closest)} rows=${rows}`, + { status: 500 }, + ); + } + + return new Response(`OK marks=${marks} rows=${rows}`); + }, +}; diff --git a/packages/core/scripts/workerd-smoke/wrangler.toml b/packages/core/scripts/workerd-smoke/wrangler.toml new file mode 100644 index 00000000..a8568d26 --- /dev/null +++ b/packages/core/scripts/workerd-smoke/wrangler.toml @@ -0,0 +1,4 @@ +name = "platform-core-linkedom-smoke" +main = "worker.mjs" +compatibility_date = "2025-05-01" +compatibility_flags = ["nodejs_compat"] diff --git a/packages/core/src/bible-html-adapters.ts b/packages/core/src/bible-html-adapters.ts new file mode 100644 index 00000000..65ba9adb --- /dev/null +++ b/packages/core/src/bible-html-adapters.ts @@ -0,0 +1,62 @@ +import type { TransformBibleHtmlOptions } from './bible-html-transformer'; + +// linkedom's typed return is a union of document kinds that is not assignable +// to the lib DOM `Document`. Call sites cast the parse result. +type DomParserInstance = { + parseFromString(html: string, type: string): unknown; +}; +type DomParserConstructor = new () => DomParserInstance; + +/** + * Native browser / DOMParser-global adapters. + */ +export function createDomParserAdapters(): TransformBibleHtmlOptions { + if (typeof globalThis.DOMParser === 'undefined') { + throw new Error('DOMParser is required to transform Bible HTML in browser environments'); + } + + return { + parseHtml: (h) => + new globalThis.DOMParser().parseFromString(h, 'text/html') as unknown as Document, + serializeHtml: (doc) => doc.body.innerHTML, + }; +} + +/** + * linkedom adapters. Wrap fragment HTML in `` so `doc.body.innerHTML` + * serializes correctly. + */ +export function createLinkedomAdapters( + DOMParserCtor: DomParserConstructor, +): TransformBibleHtmlOptions { + return { + parseHtml: (h) => + new DOMParserCtor().parseFromString( + `${h}`, + 'text/html', + ) as Document, + serializeHtml: (doc) => doc.body.innerHTML, + }; +} + +/** + * Runtime detection for `getPassage` auto-transform: + * native `DOMParser` when present, otherwise dynamic `linkedom`. + */ +export async function resolveHtmlAdapters(): Promise { + if (typeof globalThis.DOMParser !== 'undefined') { + return createDomParserAdapters(); + } + + let linkedom: { DOMParser: DomParserConstructor }; + try { + linkedom = await import('linkedom'); + } catch { + throw new Error( + 'Server-side HTML transformation requires "linkedom". ' + + 'Install it as a dependency or pass transform: false to skip transformation.', + ); + } + + return createLinkedomAdapters(linkedom.DOMParser); +} diff --git a/packages/core/src/bible-html-transformer-server.ts b/packages/core/src/bible-html-transformer-server.ts index 6b42819c..8dd1d38a 100644 --- a/packages/core/src/bible-html-transformer-server.ts +++ b/packages/core/src/bible-html-transformer-server.ts @@ -1,16 +1,20 @@ -import { JSDOM } from 'jsdom'; +import { DOMParser } from 'linkedom'; +import { createLinkedomAdapters } from './bible-html-adapters'; import { transformBibleHtml as transformBibleHtmlWithAdapters, type TransformedBibleHtml, } from './bible-html-transformer'; /** - * Transforms Bible HTML for server environments using jsdom. + * Transforms Bible HTML for server / edge environments using linkedom. * - * Import from `@youversion/platform-core/server` to avoid bundling jsdom + * Import from `@youversion/platform-core/server` to avoid bundling linkedom * in client-side builds. * + * linkedom requires HTML to be wrapped in body tags for `doc.body.innerHTML` + * to work correctly; the shared adapters handle that wrapping. + * * @param html - The raw Bible HTML from the YouVersion API * @returns The transformed HTML * @@ -23,9 +27,5 @@ import { * ``` */ export function transformBibleHtml(html: string): TransformedBibleHtml { - return transformBibleHtmlWithAdapters(html, { - parseHtml: (h: string) => - new JSDOM(`${h}`).window.document, - serializeHtml: (doc: Document) => doc.body.innerHTML, - }); + return transformBibleHtmlWithAdapters(html, createLinkedomAdapters(DOMParser)); } diff --git a/packages/core/src/bible-html-transformer.server.test.ts b/packages/core/src/bible-html-transformer.server.test.ts index 1df9f0b6..e99be0ac 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 jsdom', () => { + it('should transform HTML using linkedom', () => { const html = `
@@ -58,7 +58,7 @@ describe('transformBibleHtml', () => { const result = transformBibleHtml(html); - // jsdom may serialize attributes in different order than browsers + // linkedom 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,7 +77,7 @@ describe('transformBibleHtml', () => { const result = transformBibleHtml(html); - // jsdom may encode non-breaking space as   instead of the raw character + // linkedom may encode non-breaking space as   instead of the raw character expect(result.html).toMatch(/1(\u00A0| | )/); }); @@ -137,7 +137,7 @@ describe('transformBibleHtml', () => { expect(result.html).toContain('Click me'); }); - it('should preserve safe Bible HTML through jsdom', () => { + it('should preserve safe Bible HTML through linkedom', () => { const html = `
Jesus said @@ -148,4 +148,28 @@ describe('transformBibleHtml', () => { expect(result.html).toContain('class="wj"'); expect(result.html).toContain('Jesus said'); }); + + it('table rows and colspan survive browser reparse without server ', async () => { + // linkedom does not inject ; browsers/jsdom do. Consumers that set + // server HTML via dangerouslySetInnerHTML must still see repaired tables. + const html = ` +
+ + + +
alone
ab
+
+ `; + + const server = transformBibleHtml(html); + expect(server.html.toLowerCase()).not.toContain(' new DOMParser().parseFromString(h, 'text/html'), - serializeHtml: (doc) => doc.body.innerHTML, - }); + return transformBibleHtml(html, createDomParserAdapters()); } diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 5fa128c1..79c49c6d 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { ApiClient } from './client'; -import { transformBibleHtml, type TransformBibleHtmlOptions } from './bible-html-transformer'; +import { resolveHtmlAdapters } from './bible-html-adapters'; +import { transformBibleHtml } from './bible-html-transformer'; import { BibleVersionSchema } from './schemas'; import type { BibleBook, @@ -14,31 +15,6 @@ 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, - }; - } - let jsdom; - try { - jsdom = await import('jsdom'); - } catch { - throw new Error( - 'Server-side HTML transformation requires "jsdom". ' + - 'Install it as a dependency or pass transform: false to skip transformation.', - ); - } - return { - parseHtml: (h) => - new jsdom.JSDOM(`${h}`).window - .document as unknown as Document, - serializeHtml: (doc) => doc.body.innerHTML, - }; -} - /** * Client for interacting with Bible API endpoints. */ @@ -275,7 +251,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 `jsdom` dependency on the server. + * needed. Also avoids the `linkedom` dependency on the server. * @returns The requested BiblePassage object. * * @example @@ -292,7 +268,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 jsdom needed on server) + * // Get raw, untransformed HTML (no linkedom needed on server) * const raw = await bibleClient.getPassage(3034, "JHN.3.16", "html", undefined, undefined, false); * ``` */ @@ -326,7 +302,7 @@ export class BibleClient { ); if (format === 'html' && transform !== false) { - const adapters = await getHtmlAdapters(); + const adapters = await resolveHtmlAdapters(); const { html } = transformBibleHtml(passage.content, adapters); return { ...passage, content: html }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baf8e923..c7b5b8e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: link:../../tools/tsconfig '@types/jsdom': specifier: ^28.0.1 - version: 28.0.1 + version: 28.0.3 '@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.3.3(@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))(vitest@4.0.4) @@ -167,6 +167,9 @@ importers: jsdom: specifier: 24.0.0 version: 24.0.0 + linkedom: + specifier: 0.18.13 + version: 0.18.13 msw: specifier: 2.11.6 version: 2.11.6(@types/node@24.11.0)(typescript@5.9.3) @@ -2915,8 +2918,8 @@ 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/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3292,6 +3295,10 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3515,10 +3522,18 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + 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@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -3527,6 +3542,9 @@ 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'} @@ -3672,6 +3690,35 @@ 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==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dotenv-cli@7.4.2: resolution: {integrity: sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==} hasBin: true @@ -3741,10 +3788,22 @@ 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'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4256,9 +4315,15 @@ 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'} @@ -4832,6 +4897,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + 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'} @@ -5117,6 +5191,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} @@ -6178,6 +6256,9 @@ 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'} @@ -6185,8 +6266,8 @@ 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==} + undici-types@7.29.0: + resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -7524,7 +7605,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: @@ -9165,12 +9246,12 @@ snapshots: '@types/istanbul-lib-coverage@2.0.6': {} - '@types/jsdom@28.0.1': + '@types/jsdom@28.0.3': dependencies: '@types/node': 24.11.0 '@types/tough-cookie': 4.0.5 - parse5: 7.3.0 - undici-types: 7.25.0 + parse5: 8.0.0 + undici-types: 7.29.0 '@types/json-schema@7.0.15': {} @@ -9773,6 +9854,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@2.0.0: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -9972,15 +10055,27 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 + css-what@8.0.0: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} + cssom@0.5.0: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -10097,6 +10192,42 @@ 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 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dotenv-cli@7.4.2: dependencies: cross-spawn: 7.0.6 @@ -10158,8 +10289,14 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} + + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -10918,10 +11055,19 @@ 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 @@ -11460,6 +11606,14 @@ snapshots: lines-and-columns@1.2.4: {} + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + 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 @@ -11734,6 +11888,10 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + nwsapi@2.2.22: {} object-assign@4.1.1: {} @@ -13004,6 +13162,8 @@ snapshots: ufo@1.6.1: {} + uhyphen@0.2.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -13013,7 +13173,7 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.25.0: {} + undici-types@7.29.0: {} unicorn-magic@0.3.0: {} From 5f5b41414ad4a15ed89669792e731d8216231e5a Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 08:41:14 -0500 Subject: [PATCH 08/15] chore(core): bump jsdom devDep to 28.1.0 Aligns runtime jsdom with @types/jsdom@^28 so Greptile stops flagging the major skew. jsdom is test-only here (vitest + tbody browser stand-in). Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- packages/core/package.json | 2 +- pnpm-lock.yaml | 440 +++++++++++++++++++------------------ 2 files changed, 231 insertions(+), 211 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index deb2f17c..30c2c4c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,7 +53,7 @@ "@vitest/coverage-v8": "4.0.4", "dotenv-cli": "7.4.2", "eslint": "9.38.0", - "jsdom": "24.0.0", + "jsdom": "28.1.0", "linkedom": "0.18.13", "msw": "2.11.6", "tsup": "8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7b5b8e5..81f12543 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,8 +165,8 @@ importers: specifier: 9.38.0 version: 9.38.0(jiti@2.6.1) jsdom: - specifier: 24.0.0 - version: 24.0.0 + specifier: 28.1.0 + version: 28.1.0(@noble/hashes@1.8.0) linkedom: specifier: 0.18.13 version: 0.18.13 @@ -181,7 +181,7 @@ importers: version: 5.9.3 vitest: specifier: 4.0.4 - version: 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) + version: 4.0.4(@types/node@24.11.0)(@vitest/browser-playwright@4.0.4)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.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) packages/hooks: dependencies: @@ -423,6 +423,9 @@ importers: packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} @@ -430,15 +433,23 @@ packages: resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} hasBin: true - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/dom-selector@6.7.3': resolution: {integrity: sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==} + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} @@ -675,6 +686,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.0.13': resolution: {integrity: sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg==} @@ -734,6 +749,10 @@ packages: resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} engines: {node: '>=18'} @@ -741,6 +760,13 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-color-parser@3.1.0': resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} engines: {node: '>=18'} @@ -748,22 +774,47 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} peerDependencies: '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-syntax-patches-for-csstree@1.0.14': resolution: {integrity: sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==} engines: {node: '>=18'} peerDependencies: postcss: ^8.4 + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dotenvx/dotenvx@1.52.0': resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true @@ -1159,6 +1210,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -3259,9 +3319,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3441,10 +3498,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3545,14 +3598,14 @@ packages: cssom@0.5.0: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} - cssstyle@5.3.1: resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} engines: {node: '>=20'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -3560,14 +3613,14 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3640,10 +3693,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4101,10 +4150,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -4312,6 +4357,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -4663,18 +4712,18 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsdom@24.0.0: - resolution: {integrity: sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==} - engines: {node: '>=18'} + jsdom@27.0.1: + resolution: {integrity: sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==} + engines: {node: '>=20'} peerDependencies: - canvas: ^2.11.2 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - jsdom@27.0.1: - resolution: {integrity: sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==} - engines: {node: '>=20'} + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -4961,14 +5010,14 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.1: - resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} - engines: {node: 20 || >=22} - lru-cache@11.2.2: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5045,18 +5094,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -5195,9 +5236,6 @@ packages: resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} engines: {node: '>=20.19.0'} - nwsapi@2.2.22: - resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5323,9 +5361,6 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -5500,9 +5535,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5514,9 +5546,6 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5655,9 +5684,6 @@ packages: resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} engines: {node: '>=0.10.5'} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5711,9 +5737,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - rrweb-cssom@0.6.0: - resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} - rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -6098,10 +6121,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -6113,10 +6132,6 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -6269,6 +6284,10 @@ packages: undici-types@7.29.0: resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -6277,10 +6296,6 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -6311,9 +6326,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6471,14 +6483,14 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - webidl-conversions@8.0.0: resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} engines: {node: '>=20'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -6491,14 +6503,18 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} whatwg-url@15.1.0: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -6640,6 +6656,8 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + '@adobe/css-tools@4.4.4': {} '@antfu/ni@25.0.0': @@ -6649,21 +6667,21 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@asamuzakjp/css-color@3.2.0': + '@asamuzakjp/css-color@4.0.5': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 + lru-cache: 11.2.2 - '@asamuzakjp/css-color@4.0.5': + '@asamuzakjp/css-color@5.1.11': dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.1 + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 '@asamuzakjp/dom-selector@6.7.3': dependencies: @@ -6673,6 +6691,16 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.2.2 + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/generational-cache@1.0.1': {} + '@asamuzakjp/nwsapi@2.3.9': {} '@babel/code-frame@7.27.1': @@ -6996,7 +7024,7 @@ snapshots: '@babel/parser': 7.28.4 '@babel/template': 7.27.2 '@babel/types': 7.28.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7041,6 +7069,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.1.0 + '@changesets/apply-release-plan@7.0.13': dependencies: '@changesets/config': 3.1.1 @@ -7187,11 +7219,18 @@ snapshots: '@csstools/color-helpers@5.1.0': {} + '@csstools/color-helpers@6.1.0': {} + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/color-helpers': 5.1.0 @@ -7199,16 +7238,33 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-syntax-patches-for-csstree@1.0.14(postcss@8.5.6)': dependencies: postcss: 8.5.6 + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.1.0)': + optionalDependencies: + css-tree: 3.1.0 + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@4.0.0': {} + '@dotenvx/dotenvx@1.52.0': dependencies: commander: 11.1.0 @@ -7475,6 +7531,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -9471,7 +9531,7 @@ snapshots: '@vitest/mocker': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1)) playwright: 1.56.1 tinyrainbow: 3.0.3 - 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@28.1.0(@noble/hashes@1.8.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) transitivePeerDependencies: - bufferutil - msw @@ -9501,7 +9561,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - 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@28.1.0(@noble/hashes@1.8.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) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -9540,7 +9600,7 @@ snapshots: magicast: 0.3.5 std-env: 3.9.0 tinyrainbow: 3.0.3 - 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@28.1.0(@noble/hashes@1.8.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) optionalDependencies: '@vitest/browser': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@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) transitivePeerDependencies: @@ -9816,8 +9876,6 @@ snapshots: async-function@1.0.0: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -9991,10 +10049,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@11.1.0: {} commander@14.0.1: {} @@ -10076,11 +10130,6 @@ snapshots: cssom@0.5.0: {} - cssstyle@4.6.0: - dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 - cssstyle@5.3.1(postcss@8.5.6): dependencies: '@asamuzakjp/css-color': 4.0.5 @@ -10089,20 +10138,29 @@ snapshots: transitivePeerDependencies: - postcss + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.1.0) + css-tree: 3.1.0 + lru-cache: 11.5.2 + csstype@3.1.3: {} data-uri-to-buffer@4.0.1: {} - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -10160,8 +10218,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -10839,14 +10895,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -11053,6 +11101,12 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -11079,14 +11133,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -11385,61 +11439,60 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@24.0.0: + jsdom@27.0.1(postcss@8.5.6): dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 + '@asamuzakjp/dom-selector': 6.7.3 + cssstyle: 5.3.1(postcss@8.5.6) + data-urls: 6.0.0 decimal.js: 10.6.0 - form-data: 4.0.4 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 - parse5: 7.3.0 - rrweb-cssom: 0.6.0 + parse5: 8.0.0 + rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 4.1.4 + tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 + webidl-conversions: 8.0.0 whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 + whatwg-url: 15.1.0 ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil + - postcss - supports-color - utf-8-validate - jsdom@27.0.1(postcss@8.5.6): + jsdom@28.1.0(@noble/hashes@1.8.0): dependencies: - '@asamuzakjp/dom-selector': 6.7.3 - cssstyle: 5.3.1(postcss@8.5.6) - data-urls: 6.0.0 + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + cssstyle: 6.2.0 + data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 parse5: 8.0.0 - rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 6.0.0 + tough-cookie: 6.0.1 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 15.1.0 - ws: 8.18.3 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - - bufferutil - - postcss + - '@noble/hashes' - supports-color - - utf-8-validate jsesc@3.1.0: {} @@ -11678,10 +11731,10 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.1: {} - lru-cache@11.2.2: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11739,14 +11792,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -11892,8 +11939,6 @@ snapshots: dependencies: boolbase: 2.0.0 - nwsapi@2.2.22: {} - object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -12062,10 +12107,6 @@ snapshots: parse-ms@4.0.0: {} - parse5@7.3.0: - dependencies: - entities: 6.0.1 - parse5@8.0.0: dependencies: entities: 6.0.1 @@ -12091,7 +12132,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.2.1 + lru-cache: 11.2.2 minipass: 7.1.2 path-scurry@2.0.2: @@ -12198,10 +12239,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - punycode@2.3.1: {} qs@6.15.0: @@ -12210,8 +12247,6 @@ snapshots: quansync@0.2.11: {} - querystringify@2.2.0: {} - queue-microtask@1.2.3: {} radix-ui@1.4.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2): @@ -12406,8 +12441,6 @@ snapshots: requireindex@1.1.0: {} - requires-port@1.0.0: {} - resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -12485,8 +12518,6 @@ snapshots: transitivePeerDependencies: - supports-color - rrweb-cssom@0.6.0: {} - rrweb-cssom@0.8.0: {} run-applescript@7.1.0: {} @@ -12946,13 +12977,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@4.1.4: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - tough-cookie@6.0.0: dependencies: tldts: 7.0.17 @@ -12965,10 +12989,6 @@ snapshots: dependencies: punycode: 2.3.1 - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -13175,12 +13195,12 @@ snapshots: undici-types@7.29.0: {} + undici@7.29.0: {} + unicorn-magic@0.3.0: {} universalify@0.1.2: {} - universalify@0.2.0: {} - universalify@2.0.1: {} unpipe@1.0.0: {} @@ -13210,11 +13230,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - use-callback-ref@1.3.3(@types/react@19.1.2)(react@19.1.2): dependencies: react: 19.1.2 @@ -13283,10 +13298,10 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - 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@27.0.1(postcss@8.5.6))(lightningcss@1.31.1)(msw@2.13.4(@types/node@24.11.0)(typescript@5.9.3))(terser@5.44.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.4 - '@vitest/mocker': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 4.0.4(msw@2.13.4(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.4 '@vitest/runner': 4.0.4 '@vitest/snapshot': 4.0.4 @@ -13307,8 +13322,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.11.0 - '@vitest/browser-playwright': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(playwright@1.56.1)(vite@7.3.3(@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) - jsdom: 24.0.0 + '@vitest/browser-playwright': 4.0.4(msw@2.13.4(@types/node@24.11.0)(typescript@5.9.3))(playwright@1.56.1)(vite@7.3.3(@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) + jsdom: 27.0.1(postcss@8.5.6) transitivePeerDependencies: - jiti - less @@ -13323,10 +13338,10 @@ snapshots: - tsx - yaml - vitest@4.0.4(@types/node@24.11.0)(@vitest/browser-playwright@4.0.4)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.31.1)(msw@2.13.4(@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@28.1.0(@noble/hashes@1.8.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): dependencies: '@vitest/expect': 4.0.4 - '@vitest/mocker': 4.0.4(msw@2.13.4(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.4 '@vitest/runner': 4.0.4 '@vitest/snapshot': 4.0.4 @@ -13347,8 +13362,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.11.0 - '@vitest/browser-playwright': 4.0.4(msw@2.13.4(@types/node@24.11.0)(typescript@5.9.3))(playwright@1.56.1)(vite@7.3.3(@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) - jsdom: 27.0.1(postcss@8.5.6) + '@vitest/browser-playwright': 4.0.4(msw@2.11.6(@types/node@24.11.0)(typescript@5.9.3))(playwright@1.56.1)(vite@7.3.3(@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) + jsdom: 28.1.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -13375,10 +13390,10 @@ snapshots: webidl-conversions@4.0.2: {} - webidl-conversions@7.0.0: {} - webidl-conversions@8.0.0: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -13387,16 +13402,21 @@ snapshots: whatwg-mimetype@4.0.0: {} - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 + whatwg-mimetype@5.0.0: {} whatwg-url@15.1.0: dependencies: tr46: 6.0.0 webidl-conversions: 8.0.0 + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 From 2ee2a9a3426259f94fc1e7c58831164a3aee87aa Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 08:49:10 -0500 Subject: [PATCH 09/15] fix(core): smoke real transform path in workerd Point the workerd CI smoke at resolveHtmlAdapters + transformBibleHtml so a Node-only import or broken dynamic linkedom load fails the gate. Surface the original import error alongside the install guidance. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- .../core/scripts/workerd-smoke/worker.mjs | 45 ++++++++----------- packages/core/src/bible-html-adapters.ts | 7 ++- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/packages/core/scripts/workerd-smoke/worker.mjs b/packages/core/scripts/workerd-smoke/worker.mjs index 8edbea59..9c278e7a 100644 --- a/packages/core/scripts/workerd-smoke/worker.mjs +++ b/packages/core/scripts/workerd-smoke/worker.mjs @@ -1,37 +1,30 @@ -import { DOMParser } from 'linkedom'; +import { resolveHtmlAdapters } from '../../src/bible-html-adapters.ts'; +import { transformBibleHtml } from '../../src/bible-html-transformer.ts'; /** - * Minimal edge smoke: linkedom must parse + query in workerd. - * Catches the class of failure where a DOM lib bundles cleanly then dies at runtime. + * Edge smoke against the shipped path: dynamic `import('linkedom')` inside + * `resolveHtmlAdapters`, then the real transform. A hand-rolled linkedom + * DOMParser check would stay green while our adapters broke. */ +const RAW = `
1In the beginningA note.
a
bc
`; + export default { async fetch() { - const doc = new DOMParser().parseFromString( - ` -
- 1Hi - note. -
-
a
bc
- `, - 'text/html', - ); - - const marks = doc.querySelectorAll('.yv-v[v]').length; - const footnote = doc.querySelector('.yv-n.f'); - const closest = footnote?.closest('.p, p, div.p'); - const rows = doc.querySelectorAll('table tr').length; - const replaced = doc.createElement('span'); - replaced.setAttribute('data-ok', '1'); - footnote?.replaceWith(replaced); - - if (marks !== 1 || !closest || rows < 2 || !doc.querySelector('[data-ok="1"]')) { + try { + const adapters = await resolveHtmlAdapters(); + const { html } = transformBibleHtml(RAW, adapters); + if ( + !html.includes('data-yv-transformed') || + !html.includes('data-verse-footnote') + ) { + return new Response(`FAIL ${html}`, { status: 500 }); + } + return new Response('OK'); + } catch (e) { return new Response( - `FAIL marks=${marks} closest=${Boolean(closest)} rows=${rows}`, + `FAIL: ${e instanceof Error ? e.message : String(e)}`, { status: 500 }, ); } - - return new Response(`OK marks=${marks} rows=${rows}`); }, }; diff --git a/packages/core/src/bible-html-adapters.ts b/packages/core/src/bible-html-adapters.ts index 65ba9adb..095f36d8 100644 --- a/packages/core/src/bible-html-adapters.ts +++ b/packages/core/src/bible-html-adapters.ts @@ -51,10 +51,13 @@ export async function resolveHtmlAdapters(): Promise let linkedom: { DOMParser: DomParserConstructor }; try { linkedom = await import('linkedom'); - } catch { + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); throw new Error( 'Server-side HTML transformation requires "linkedom". ' + - 'Install it as a dependency or pass transform: false to skip transformation.', + 'Install it as a dependency or pass transform: false to skip transformation. ' + + `Original error: ${detail}`, + { cause: err }, ); } From 4e017f616c4c94f5a9b3fc06b8b74e434f69a70d Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 09:06:17 -0500 Subject: [PATCH 10/15] chore(core): remove workerd linkedom smoke testing Cam requested dropping the workerd CI gate; linkedom remains the runtime peer and jsdom stays test-only for vitest/browser reparse. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- .github/workflows/ci.yml | 22 ----- .gitignore | 1 - packages/core/package.json | 3 +- .../core/scripts/workerd-linkedom-smoke.mjs | 96 ------------------- .../core/scripts/workerd-smoke/worker.mjs | 30 ------ .../core/scripts/workerd-smoke/wrangler.toml | 4 - 6 files changed, 1 insertion(+), 155 deletions(-) delete mode 100644 packages/core/scripts/workerd-linkedom-smoke.mjs delete mode 100644 packages/core/scripts/workerd-smoke/worker.mjs delete mode 100644 packages/core/scripts/workerd-smoke/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70039b34..8d171f64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,28 +187,6 @@ jobs: git diff --staged --quiet || git commit -m "chore: update coverage badges" git push - workerd-linkedom: - name: Workerd linkedom smoke - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run linkedom inside workerd - run: pnpm --filter @youversion/platform-core test:workerd - build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 79697b3b..7c2ab038 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,3 @@ storybook-static .opencode/ .superset/ .codex/ -packages/core/scripts/workerd-smoke/.wrangler/ diff --git a/packages/core/package.json b/packages/core/package.json index 0a7d3544..5b4f8603 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,8 +43,7 @@ "test": "dotenv -e .env.local -- vitest run", "test:integration": "INTEGRATION_TESTS=true dotenv -e .env.local -- vitest run", "test:watch": "dotenv -e .env.local -- vitest", - "test:coverage": "dotenv -e .env.local -- vitest run --coverage", - "test:workerd": "node ./scripts/workerd-linkedom-smoke.mjs" + "test:coverage": "dotenv -e .env.local -- vitest run --coverage" }, "devDependencies": { "@internal/eslint-config": "workspace:*", diff --git a/packages/core/scripts/workerd-linkedom-smoke.mjs b/packages/core/scripts/workerd-linkedom-smoke.mjs deleted file mode 100644 index 60c45abb..00000000 --- a/packages/core/scripts/workerd-linkedom-smoke.mjs +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -/** - * Spawns `wrangler dev --local` against the linkedom workerd smoke worker, - * hits it once, and exits non-zero on failure. - * - * Why this exists: jsdom/happy-dom can bundle for Workers and still die on the - * first request. A dry-run deploy would miss that. This runs the real runtime. - */ -import { spawn } from 'node:child_process'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const smokeDir = path.join(__dirname, 'workerd-smoke'); -const require = createRequire(import.meta.url); - -// Resolve linkedom from packages/core so wrangler's bundler can find it. -try { - require.resolve('linkedom'); -} catch { - console.error('linkedom is not installed in @youversion/platform-core'); - process.exit(1); -} - -const port = 8787 + Math.floor(Math.random() * 1000); - -const child = spawn( - process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', - [ - 'dlx', - 'wrangler@4.118.0', - 'dev', - '--local', - '--port', - String(port), - '--config', - path.join(smokeDir, 'wrangler.toml'), - ], - { - cwd: path.join(__dirname, '..'), - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, FORCE_COLOR: '0' }, - }, -); - -let output = ''; -const onData = (buf) => { - output += buf.toString(); -}; - -child.stdout.on('data', onData); -child.stderr.on('data', onData); - -const ready = - /Ready on|Local:|http:\/\/127\.0\.0\.1:|http:\/\/localhost:/i; - -const deadline = Date.now() + 90_000; - -async function waitForReady() { - while (Date.now() < deadline) { - if (ready.test(output) || output.includes(`127.0.0.1:${port}`)) { - return; - } - if (child.exitCode !== null) { - throw new Error(`wrangler exited early (${child.exitCode})\n${output}`); - } - await new Promise((r) => setTimeout(r, 250)); - } - throw new Error(`Timed out waiting for wrangler\n${output}`); -} - -try { - await waitForReady(); - const res = await fetch(`http://127.0.0.1:${port}/`); - const body = await res.text(); - if (!res.ok || !body.startsWith('OK')) { - throw new Error(`Smoke failed: HTTP ${res.status} body=${body}\n${output}`); - } - console.log(body); - process.exitCode = 0; -} catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exitCode = 1; -} finally { - child.kill('SIGTERM'); - // Ensure the process tree does not hang CI. - setTimeout(() => { - try { - child.kill('SIGKILL'); - } catch { - // ignore - } - process.exit(process.exitCode ?? 1); - }, 2000).unref(); -} diff --git a/packages/core/scripts/workerd-smoke/worker.mjs b/packages/core/scripts/workerd-smoke/worker.mjs deleted file mode 100644 index 9c278e7a..00000000 --- a/packages/core/scripts/workerd-smoke/worker.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { resolveHtmlAdapters } from '../../src/bible-html-adapters.ts'; -import { transformBibleHtml } from '../../src/bible-html-transformer.ts'; - -/** - * Edge smoke against the shipped path: dynamic `import('linkedom')` inside - * `resolveHtmlAdapters`, then the real transform. A hand-rolled linkedom - * DOMParser check would stay green while our adapters broke. - */ -const RAW = `
1In the beginningA note.
a
bc
`; - -export default { - async fetch() { - try { - const adapters = await resolveHtmlAdapters(); - const { html } = transformBibleHtml(RAW, adapters); - if ( - !html.includes('data-yv-transformed') || - !html.includes('data-verse-footnote') - ) { - return new Response(`FAIL ${html}`, { status: 500 }); - } - return new Response('OK'); - } catch (e) { - return new Response( - `FAIL: ${e instanceof Error ? e.message : String(e)}`, - { status: 500 }, - ); - } - }, -}; diff --git a/packages/core/scripts/workerd-smoke/wrangler.toml b/packages/core/scripts/workerd-smoke/wrangler.toml deleted file mode 100644 index a8568d26..00000000 --- a/packages/core/scripts/workerd-smoke/wrangler.toml +++ /dev/null @@ -1,4 +0,0 @@ -name = "platform-core-linkedom-smoke" -main = "worker.mjs" -compatibility_date = "2025-05-01" -compatibility_flags = ["nodejs_compat"] From bf1c466a2ba09693f9ad1b9366bf0ba079183b37 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 09:21:24 -0500 Subject: [PATCH 11/15] fix(core): restore jsdom as optional peer for HTML transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the linkedom runtime choice per Cam — no workerd target. Keep native DOMParser in browser and dynamic jsdom on server. Drop bible-html-adapters and realign docs/changeset. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- .changeset/auto-transform-bible-html.md | 2 +- packages/core/AGENTS.md | 11 +- packages/core/package.json | 5 +- packages/core/src/bible-html-adapters.ts | 65 ------- .../core/src/bible-html-transformer-server.ts | 16 +- .../src/bible-html-transformer.server.test.ts | 32 +--- packages/core/src/bible-html-transformer.ts | 11 +- packages/core/src/bible.ts | 37 +++- pnpm-lock.yaml | 160 ------------------ 9 files changed, 59 insertions(+), 280 deletions(-) delete mode 100644 packages/core/src/bible-html-adapters.ts diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md index 5e42e573..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 the browser and dynamic `import('linkedom')` on server/edge runtimes (including Cloudflare Workers). `linkedom` is 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 `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 47682373..493cc042 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -23,8 +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-adapters.ts # Shared DOMParser / linkedom adapter factories -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) @@ -65,9 +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 / edge convenience wrapper (uses linkedom) - -`getPassage` auto-transform uses native `DOMParser` when present, otherwise dynamic `import('linkedom')`. That path runs on Node and Cloudflare Workers. Pass `transform: false` to skip transformation (and the linkedom peer). +- `@youversion/platform-core/server` → Server convenience wrapper (uses jsdom) **Examples:** @@ -85,7 +82,7 @@ import { transformBibleHtml } from '@youversion/platform-core/browser'; const result = transformBibleHtml(html); -// Server / edge 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); @@ -93,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 5b4f8603..3d798fb7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,17 +53,16 @@ "dotenv-cli": "7.4.2", "eslint": "9.38.0", "jsdom": "28.1.0", - "linkedom": "0.18.13", "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 } }, diff --git a/packages/core/src/bible-html-adapters.ts b/packages/core/src/bible-html-adapters.ts deleted file mode 100644 index 095f36d8..00000000 --- a/packages/core/src/bible-html-adapters.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { TransformBibleHtmlOptions } from './bible-html-transformer'; - -// linkedom's typed return is a union of document kinds that is not assignable -// to the lib DOM `Document`. Call sites cast the parse result. -type DomParserInstance = { - parseFromString(html: string, type: string): unknown; -}; -type DomParserConstructor = new () => DomParserInstance; - -/** - * Native browser / DOMParser-global adapters. - */ -export function createDomParserAdapters(): TransformBibleHtmlOptions { - if (typeof globalThis.DOMParser === 'undefined') { - throw new Error('DOMParser is required to transform Bible HTML in browser environments'); - } - - return { - parseHtml: (h) => - new globalThis.DOMParser().parseFromString(h, 'text/html') as unknown as Document, - serializeHtml: (doc) => doc.body.innerHTML, - }; -} - -/** - * linkedom adapters. Wrap fragment HTML in `` so `doc.body.innerHTML` - * serializes correctly. - */ -export function createLinkedomAdapters( - DOMParserCtor: DomParserConstructor, -): TransformBibleHtmlOptions { - return { - parseHtml: (h) => - new DOMParserCtor().parseFromString( - `${h}`, - 'text/html', - ) as Document, - serializeHtml: (doc) => doc.body.innerHTML, - }; -} - -/** - * Runtime detection for `getPassage` auto-transform: - * native `DOMParser` when present, otherwise dynamic `linkedom`. - */ -export async function resolveHtmlAdapters(): Promise { - if (typeof globalThis.DOMParser !== 'undefined') { - return createDomParserAdapters(); - } - - let linkedom: { DOMParser: DomParserConstructor }; - try { - linkedom = await import('linkedom'); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - throw new Error( - 'Server-side HTML transformation requires "linkedom". ' + - 'Install it as a dependency or pass transform: false to skip transformation. ' + - `Original error: ${detail}`, - { cause: err }, - ); - } - - return createLinkedomAdapters(linkedom.DOMParser); -} diff --git a/packages/core/src/bible-html-transformer-server.ts b/packages/core/src/bible-html-transformer-server.ts index 8dd1d38a..6b42819c 100644 --- a/packages/core/src/bible-html-transformer-server.ts +++ b/packages/core/src/bible-html-transformer-server.ts @@ -1,20 +1,16 @@ -import { DOMParser } from 'linkedom'; +import { JSDOM } from 'jsdom'; -import { createLinkedomAdapters } from './bible-html-adapters'; import { transformBibleHtml as transformBibleHtmlWithAdapters, type TransformedBibleHtml, } from './bible-html-transformer'; /** - * Transforms Bible HTML for server / edge 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; the shared adapters handle that wrapping. - * * @param html - The raw Bible HTML from the YouVersion API * @returns The transformed HTML * @@ -27,5 +23,9 @@ import { * ``` */ export function transformBibleHtml(html: string): TransformedBibleHtml { - return transformBibleHtmlWithAdapters(html, createLinkedomAdapters(DOMParser)); + return transformBibleHtmlWithAdapters(html, { + parseHtml: (h: string) => + 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 e99be0ac..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,7 +77,7 @@ describe('transformBibleHtml', () => { const result = transformBibleHtml(html); - // linkedom may encode non-breaking space as   instead of the raw character + // jsdom may encode non-breaking space as   instead of the raw character expect(result.html).toMatch(/1(\u00A0| | )/); }); @@ -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 @@ -148,28 +148,4 @@ describe('transformBibleHtml', () => { expect(result.html).toContain('class="wj"'); expect(result.html).toContain('Jesus said'); }); - - it('table rows and colspan survive browser reparse without server ', async () => { - // linkedom does not inject ; browsers/jsdom do. Consumers that set - // server HTML via dangerouslySetInnerHTML must still see repaired tables. - const html = ` -
- - - -
alone
ab
-
- `; - - const server = transformBibleHtml(html); - expect(server.html.toLowerCase()).not.toContain(' new DOMParser().parseFromString(h, 'text/html'), + serializeHtml: (doc) => doc.body.innerHTML, + }); } diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 79c49c6d..b6b154de 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import type { ApiClient } from './client'; -import { resolveHtmlAdapters } from './bible-html-adapters'; -import { transformBibleHtml } from './bible-html-transformer'; +import { transformBibleHtml, type TransformBibleHtmlOptions } from './bible-html-transformer'; import { BibleVersionSchema } from './schemas'; import type { BibleBook, @@ -15,6 +14,34 @@ 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, + }; + } + let jsdom; + try { + jsdom = await import('jsdom'); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + 'Server-side HTML transformation requires "jsdom". ' + + 'Install it as a dependency or pass transform: false to skip transformation. ' + + `Original error: ${detail}`, + { cause: err }, + ); + } + return { + parseHtml: (h) => + new jsdom.JSDOM(`${h}`).window + .document as unknown as Document, + serializeHtml: (doc) => doc.body.innerHTML, + }; +} + /** * Client for interacting with Bible API endpoints. */ @@ -251,7 +278,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 @@ -268,7 +295,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); * ``` */ @@ -302,7 +329,7 @@ export class BibleClient { ); if (format === 'html' && transform !== false) { - const adapters = await resolveHtmlAdapters(); + const adapters = await getHtmlAdapters(); const { html } = transformBibleHtml(passage.content, adapters); return { ...passage, content: html }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d88571c2..06c9e1bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,6 @@ importers: jsdom: specifier: 28.1.0 version: 28.1.0(@noble/hashes@1.8.0) - linkedom: - specifier: 0.18.13 - version: 0.18.13 msw: specifier: 2.11.6 version: 2.11.6(@types/node@24.11.0)(typescript@5.9.3) @@ -3437,10 +3434,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - boolbase@2.0.0: - resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} - engines: {node: '>=20.19.0'} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3684,18 +3677,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@7.0.0: - resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} - engines: {node: '>=20.19.0'} - 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@8.0.0: - resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} - engines: {node: '>=20.19.0'} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -3704,9 +3689,6 @@ packages: engines: {node: '>=4'} hasBin: true - cssom@0.5.0: - resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - cssstyle@5.3.1: resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} engines: {node: '>=20'} @@ -3852,35 +3834,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==} - - dom-serializer@3.1.1: - resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} - engines: {node: '>=20.19.0'} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domelementtype@3.0.0: - resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} - engines: {node: '>=20.19.0'} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domhandler@6.0.1: - resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} - engines: {node: '>=20.19.0'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - domutils@4.0.2: - resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} - engines: {node: '>=20.19.0'} - dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -3954,22 +3907,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'} - - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} - engines: {node: '>=20.19.0'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4495,15 +4436,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'} @@ -5096,15 +5031,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkedom@0.18.13: - resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} - 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'} @@ -5415,10 +5341,6 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - nth-check@3.0.1: - resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} - engines: {node: '>=20.19.0'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -6477,9 +6399,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'} @@ -10247,8 +10166,6 @@ snapshots: transitivePeerDependencies: - supports-color - boolbase@2.0.0: {} - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -10471,27 +10388,15 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-select@7.0.0: - dependencies: - boolbase: 2.0.0 - css-what: 8.0.0 - domhandler: 6.0.1 - domutils: 4.0.2 - nth-check: 3.0.1 - css-tree@3.1.0: dependencies: mdn-data: 2.12.2 source-map-js: 1.2.1 - css-what@8.0.0: {} - css.escape@1.5.1: {} cssesc@3.0.0: {} - cssom@0.5.0: {} - cssstyle@5.3.1(postcss@8.5.6): dependencies: '@asamuzakjp/css-color': 4.0.5 @@ -10612,42 +10517,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 - - dom-serializer@3.1.1: - dependencies: - domelementtype: 3.0.0 - domhandler: 6.0.1 - entities: 8.0.0 - - domelementtype@2.3.0: {} - - domelementtype@3.0.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domhandler@6.0.1: - dependencies: - domelementtype: 3.0.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - domutils@4.0.2: - dependencies: - dom-serializer: 3.1.1 - domelementtype: 3.0.0 - domhandler: 6.0.1 - dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -10713,14 +10582,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - entities@4.5.0: {} - entities@6.0.1: {} - entities@7.0.1: {} - - entities@8.0.0: {} - env-paths@2.2.1: {} environment@1.1.0: {} @@ -11493,19 +11356,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 @@ -12055,14 +11909,6 @@ snapshots: lines-and-columns@1.2.4: {} - linkedom@0.18.13: - dependencies: - css-select: 7.0.0 - 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 @@ -12353,10 +12199,6 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - nth-check@3.0.1: - dependencies: - boolbase: 2.0.0 - object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -13616,8 +13458,6 @@ snapshots: ufo@1.6.1: {} - uhyphen@0.2.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 From ad5147127e6be6a7e52b7a6086e85ee14f9a429e Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 09:40:00 -0500 Subject: [PATCH 12/15] fix(core): keep jsdom out of client bundles and trust transform marker only on root Stub jsdom via package.json browser field so Vite/Rollup examples do not bundle cssstyle. Only short-circuit idempotency when data-yv-transformed is on the transform root; tighten the onclick sanitizer expect. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- packages/core/AGENTS.md | 2 +- packages/core/package.json | 3 +++ packages/core/src/bible-html-transformer.test.ts | 13 +++++++++++-- packages/core/src/bible-html-transformer.ts | 12 ++++++++---- packages/core/src/bible.ts | 2 ++ 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 493cc042..866d0529 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -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 - jsdom 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. `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 diff --git a/packages/core/package.json b/packages/core/package.json index 3d798fb7..3f4ec48e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/bible-html-transformer.test.ts b/packages/core/src/bible-html-transformer.test.ts index 8f1cd97b..65d873b7 100644 --- a/packages/core/src/bible-html-transformer.test.ts +++ b/packages/core/src/bible-html-transformer.test.ts @@ -308,8 +308,8 @@ describe('transformBibleHtml - sanitization', () => { const result = transformBibleHtml(html, createAdapters()); expect(result.html).not.toContain('onclick'); - expect(result.html).toContain(']*)?>Click me<\/p>/); }); it('should unwrap anchor tags (not in allowlist) preserving text', () => { @@ -413,6 +413,15 @@ describe('transformBibleHtml - idempotency', () => { expect(second.html).toBe(first.html); }); + + it('should not short-circuit on untrusted nested data-yv-transformed', () => { + const html = + '
1TextA note.
'; + const result = transformBibleHtml(html, createAdapters()); + + expect(result.html).toContain('data-verse-footnote'); + expect(result.html).toMatch(/^]*\bdata-yv-transformed\b/); + }); }); describe('transformBibleHtmlForBrowser - DOMParser fallback', () => { diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts index be1d4c83..2b20c342 100644 --- a/packages/core/src/bible-html-transformer.ts +++ b/packages/core/src/bible-html-transformer.ts @@ -342,10 +342,14 @@ export function transformBibleHtml( sanitizeBibleHtmlDocument(doc); - // Already transformed — skip structural transforms - if (doc.querySelector(`[${TRANSFORMED_ATTR}]`)) { + // Only trust the marker on the transform root. A nested/untrusted + // data-yv-transformed (preserved by data-* allowlist) must not skip + // verse wrapping or footnote extraction. + const root = doc.body?.firstElementChild ?? doc.body; + if (root?.hasAttribute(TRANSFORMED_ATTR)) { return { html: options.serializeHtml(doc) }; } + wrapVerseContent(doc); assignFootnoteKeys(doc); @@ -356,8 +360,8 @@ export function transformBibleHtml( fixIrregularTables(doc); // Mark as transformed for idempotency - const root = doc.body?.firstElementChild ?? doc.body; - root?.setAttribute(TRANSFORMED_ATTR, ''); + const markedRoot = doc.body?.firstElementChild ?? doc.body; + markedRoot?.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 b6b154de..6cf1fa16 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -24,6 +24,8 @@ async function getHtmlAdapters(): Promise { } let jsdom; try { + // Literal dynamic import is fine in Node. Client bundlers must not pull + // jsdom into browser graphs — see package.json "browser": { "jsdom": false }. jsdom = await import('jsdom'); } catch (err) { const detail = err instanceof Error ? err.message : String(err); From 6b20f2bf02de81bb31601ede50a3f5da50e5a5de Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 09:42:32 -0500 Subject: [PATCH 13/15] fix(ui): externalize jsdom so client builds do not inline it UI tsup was bundling platform-core's jsdom import into the React package (~11MB) and breaking vite-react. Mark jsdom external and stub it via the browser field; mirror external in core's tsup. Co-authored-by: Cursor Co-authored-by: Cameron Pak Signed-off-by: Cameron Pak --- packages/core/tsup.config.ts | 2 ++ packages/ui/package.json | 3 +++ packages/ui/tsup.config.ts | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 617608e2..a2e8c7da 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ entry: ['src/index.ts', 'src/browser.ts', 'src/server.ts'], format: ['cjs', 'esm'], dts: true, + // Keep Node-only peer out of published bundles; loaded at runtime on server. + external: ['jsdom'], env: { YVP_PUBLISH_BUILD: isPublishBuild ? 'true' : '', }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 4bf942ac..80c48dd4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,9 @@ "module": "./dist/index.js", "style": "./dist/tailwind.css", "types": "./dist/index.d.ts", + "browser": { + "jsdom": false + }, "files": [ "dist", "README.md", diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index 0880d7ec..21c59b5e 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -20,8 +20,9 @@ export default defineConfig({ // building here, and why the env var is scoped to that command alone. See // packages/core/tsup.config.ts. noExternal: ['@youversion/platform-core'], - // Consumers provide these: - external: ['react', 'react/jsx-runtime', 'react-dom'], + // Consumers provide these. jsdom is Node-only (optional peer of platform-core); + // never inline it into the UI browser bundle. + external: ['react', 'react/jsx-runtime', 'react-dom', 'jsdom'], dts: false, // types come from `tsc` + API Extractor // Embed built Tailwind CSS as a global constant for runtime injection // Users don't need to manually import the CSS file From 7206abc324df6822120d0fd144ea663008deda0a Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 13:25:40 -0500 Subject: [PATCH 14/15] fix(core): trust the transform marker only when every root carries it The idempotency guard checked only the first top-level element. A fragment that mixed transformed and raw roots skipped verse wrapping and footnote extraction for the raw siblings. Check every top-level element instead, and mark every top-level element on transform so the marker means "this whole fragment came from here". Co-Authored-By: Claude Opus 5 --- .../core/src/bible-html-transformer.test.ts | 27 ++++++++++++++++++- packages/core/src/bible-html-transformer.ts | 23 ++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/core/src/bible-html-transformer.test.ts b/packages/core/src/bible-html-transformer.test.ts index 65d873b7..01a6630f 100644 --- a/packages/core/src/bible-html-transformer.test.ts +++ b/packages/core/src/bible-html-transformer.test.ts @@ -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(''); + expect(result.html).toMatch(/]*)?>/); }); it('should unwrap unknown custom elements preserving text', () => { @@ -422,6 +422,31 @@ describe('transformBibleHtml - idempotency', () => { expect(result.html).toContain('data-verse-footnote'); expect(result.html).toMatch(/^]*\bdata-yv-transformed\b/); }); + + it('should transform raw siblings when only the first top-level element is marked', () => { + const raw = + '
2SecondSibling note.
'; + const transformed = transformBibleHtml( + '
1First.
', + 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 = + '
1One.
' + + '
2Two.
'; + 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', () => { diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts index 2b20c342..826ee3b9 100644 --- a/packages/core/src/bible-html-transformer.ts +++ b/packages/core/src/bible-html-transformer.ts @@ -49,6 +49,10 @@ const DROP_ENTIRELY_TAGS = new Set([ const ALLOWED_ATTRS = new Set(['class', 'v', 'colspan', 'rowspan', 'dir', 'usfm']); +function topLevelElements(doc: Document): Element[] { + return doc.body ? Array.from(doc.body.children) : []; +} + function sanitizeBibleHtmlDocument(doc: Document): void { const root = doc.body ?? doc.documentElement; for (const el of Array.from(root.querySelectorAll('*'))) { @@ -342,11 +346,12 @@ export function transformBibleHtml( sanitizeBibleHtmlDocument(doc); - // Only trust the marker on the transform root. A nested/untrusted - // data-yv-transformed (preserved by data-* allowlist) must not skip - // verse wrapping or footnote extraction. - const root = doc.body?.firstElementChild ?? doc.body; - if (root?.hasAttribute(TRANSFORMED_ATTR)) { + // Only trust the marker when every top-level element carries it. A nested or + // partially-marked fragment (transformed HTML concatenated with raw HTML, or + // an untrusted data-yv-transformed preserved by the data-* allowlist) must + // still run verse wrapping and footnote extraction. + const roots = topLevelElements(doc); + if (roots.length > 0 && roots.every((el) => el.hasAttribute(TRANSFORMED_ATTR))) { return { html: options.serializeHtml(doc) }; } @@ -359,9 +364,11 @@ export function transformBibleHtml( addNbspToVerseLabels(doc); fixIrregularTables(doc); - // Mark as transformed for idempotency - const markedRoot = doc.body?.firstElementChild ?? doc.body; - markedRoot?.setAttribute(TRANSFORMED_ATTR, ''); + // Mark every top-level element so a later re-transform can tell the whole + // fragment came from here, not just its first sibling. + for (const el of topLevelElements(doc)) { + el.setAttribute(TRANSFORMED_ATTR, ''); + } const transformedHtml = options.serializeHtml(doc); return { html: transformedHtml }; From 747c9050685c8ad97d19b6b5688b57b072af9efc Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 4 Aug 2026 13:32:31 -0500 Subject: [PATCH 15/15] feat(hooks): forward transform option through usePassage BibleClient.getPassage has a transform escape hatch for skipping HTML transformation, but usePassage dropped it. Callers who want raw HTML, or who run without the optional jsdom peer in a non-DOM environment, had no way to ask for it through the hook. Co-Authored-By: Claude Opus 5 --- .changeset/auto-transform-bible-html.md | 2 +- packages/hooks/src/usePassage.test.tsx | 42 ++++++++++++++++++------- packages/hooks/src/usePassage.ts | 12 +++++-- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.changeset/auto-transform-bible-html.md b/.changeset/auto-transform-bible-html.md index f906d672..49ba44e9 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('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. +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. diff --git a/packages/hooks/src/usePassage.test.tsx b/packages/hooks/src/usePassage.test.tsx index 88e81290..11b0b074 100644 --- a/packages/hooks/src/usePassage.test.tsx +++ b/packages/hooks/src/usePassage.test.tsx @@ -46,7 +46,25 @@ describe('usePassage', () => { expect(result.current.loading).toBe(false); }); - expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', false, false); + expect + .soft(mockGetPassage) + .toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', false, false, true); + }); + + it('should forward transform: false so callers can opt out of HTML transformation', async () => { + const wrapper = createYVWrapper(); + const { result } = renderHook( + () => usePassage({ versionId: 3034, usfm: 'JHN.3.16', transform: false }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect + .soft(mockGetPassage) + .toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', false, false, false); }); }); @@ -76,7 +94,9 @@ describe('usePassage', () => { expect(result.current.loading).toBe(false); }); - expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'text', false, false); + expect + .soft(mockGetPassage) + .toHaveBeenCalledWith(3034, 'JHN.3.16', 'text', false, false, true); }); }); @@ -92,7 +112,7 @@ describe('usePassage', () => { expect(result.current.loading).toBe(false); }); - expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', true, false); + expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', true, false, true); }); it('should pass include_notes=true', async () => { @@ -106,7 +126,7 @@ describe('usePassage', () => { expect(result.current.loading).toBe(false); }); - expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', false, true); + expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3.16', 'html', false, true, true); }); it('should pass all options combined', async () => { @@ -127,7 +147,7 @@ describe('usePassage', () => { expect(result.current.loading).toBe(false); }); - expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3', 'text', true, true); + expect.soft(mockGetPassage).toHaveBeenCalledWith(3034, 'JHN.3', 'text', true, true, true); }); }); @@ -139,8 +159,8 @@ describe('usePassage', () => { usePassage({ versionId: val as number, usfm: 'JHN.3.16' }), initial: { val: 1 }, updated: { val: 3034 }, - expectedInitial: [1, 'JHN.3.16', 'html', false, false], - expectedUpdated: [3034, 'JHN.3.16', 'html', false, false], + expectedInitial: [1, 'JHN.3.16', 'html', false, false, true], + expectedUpdated: [3034, 'JHN.3.16', 'html', false, false, true], }, { param: 'usfm', @@ -148,8 +168,8 @@ describe('usePassage', () => { usePassage({ versionId: 3034, usfm: val as string }), initial: { val: 'JHN.3.16' }, updated: { val: 'GEN.1.1' }, - expectedInitial: [3034, 'JHN.3.16', 'html', false, false], - expectedUpdated: [3034, 'GEN.1.1', 'html', false, false], + expectedInitial: [3034, 'JHN.3.16', 'html', false, false, true], + expectedUpdated: [3034, 'GEN.1.1', 'html', false, false, true], }, { param: 'format', @@ -157,8 +177,8 @@ describe('usePassage', () => { usePassage({ versionId: 3034, usfm: 'JHN.3.16', format: val as 'html' | 'text' }), initial: { val: 'html' }, updated: { val: 'text' }, - expectedInitial: [3034, 'JHN.3.16', 'html', false, false], - expectedUpdated: [3034, 'JHN.3.16', 'text', false, false], + expectedInitial: [3034, 'JHN.3.16', 'html', false, false, true], + expectedUpdated: [3034, 'JHN.3.16', 'text', false, false, true], }, ])( 'should refetch when $param changes', diff --git a/packages/hooks/src/usePassage.ts b/packages/hooks/src/usePassage.ts index 1be29191..bbdaf6e4 100644 --- a/packages/hooks/src/usePassage.ts +++ b/packages/hooks/src/usePassage.ts @@ -10,6 +10,12 @@ type usePassageProps = { format?: 'html' | 'text'; include_headings?: boolean; include_notes?: boolean; + /** + * Whether to auto-transform HTML content (default: `true`). Set to `false` + * to receive the original, untransformed HTML from the API — useful when + * running outside a DOM environment without the optional `jsdom` peer. + */ + transform?: boolean; options?: UseApiDataOptions; }; @@ -19,6 +25,7 @@ export function usePassage({ format = 'html', include_headings = false, include_notes = false, + transform = true, options, }: usePassageProps): { passage: BiblePassage | null; @@ -32,8 +39,9 @@ export function usePassage({ const isValidUsfm = Boolean(usfm) && usfm !== 'undefined' && usfm !== 'null'; const { data, loading, error, refetch } = useApiData( - () => bibleClient.getPassage(versionId, usfm, format, include_headings, include_notes), - [bibleClient, versionId, usfm, format, include_headings, include_notes], + () => + bibleClient.getPassage(versionId, usfm, format, include_headings, include_notes, transform), + [bibleClient, versionId, usfm, format, include_headings, include_notes, transform], { enabled: options?.enabled !== false && isValidUsfm }, );