@youversion/platform-core 1.20.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ const NON_BREAKING_SPACE = '\u00A0';
2
+
3
+ const FOOTNOTE_KEY_ATTR = 'data-footnote-key';
4
+
5
+ const NEEDS_SPACE_BEFORE = /^[^\s.,;:!?)}\]'"'»›]/;
6
+
7
+ const ALLOWED_TAGS = new Set([
8
+ 'DIV',
9
+ 'P',
10
+ 'SPAN',
11
+ 'SUP',
12
+ 'SUB',
13
+ 'EM',
14
+ 'STRONG',
15
+ 'I',
16
+ 'B',
17
+ 'SMALL',
18
+ 'BR',
19
+ 'SECTION',
20
+ 'TABLE',
21
+ 'THEAD',
22
+ 'TBODY',
23
+ 'TR',
24
+ 'TD',
25
+ 'TH',
26
+ ]);
27
+
28
+ const DROP_ENTIRELY_TAGS = new Set([
29
+ 'SCRIPT',
30
+ 'STYLE',
31
+ 'IFRAME',
32
+ 'OBJECT',
33
+ 'EMBED',
34
+ 'SVG',
35
+ 'MATH',
36
+ 'FORM',
37
+ 'INPUT',
38
+ 'BUTTON',
39
+ 'TEXTAREA',
40
+ 'SELECT',
41
+ 'TEMPLATE',
42
+ 'LINK',
43
+ 'META',
44
+ 'BASE',
45
+ 'NOSCRIPT',
46
+ ]);
47
+
48
+ const ALLOWED_ATTRS = new Set(['class', 'v', 'colspan', 'rowspan', 'dir', 'usfm']);
49
+
50
+ function sanitizeBibleHtmlDocument(doc: Document): void {
51
+ const root = doc.body ?? doc.documentElement;
52
+ for (const el of Array.from(root.querySelectorAll('*'))) {
53
+ const tag = el.tagName;
54
+
55
+ if (DROP_ENTIRELY_TAGS.has(tag)) {
56
+ el.remove();
57
+ continue;
58
+ }
59
+
60
+ if (!ALLOWED_TAGS.has(tag)) {
61
+ el.replaceWith(...Array.from(el.childNodes));
62
+ continue;
63
+ }
64
+
65
+ for (const attr of Array.from(el.attributes)) {
66
+ const name = attr.name.toLowerCase();
67
+ if (name.startsWith('on')) {
68
+ el.removeAttribute(attr.name);
69
+ continue;
70
+ }
71
+
72
+ if (!ALLOWED_ATTRS.has(name) && !name.startsWith('data-')) {
73
+ el.removeAttribute(attr.name);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Options for transforming Bible HTML. Requires DOM adapter functions
81
+ * to parse and serialize HTML, making the transformer runtime-agnostic.
82
+ */
83
+ export type TransformBibleHtmlOptions = {
84
+ /** Parses an HTML string into a DOM Document */
85
+ parseHtml: (html: string) => Document;
86
+ /** Serializes a Document back to an HTML string */
87
+ serializeHtml: (doc: Document) => string;
88
+ };
89
+
90
+ /**
91
+ * The result of transforming Bible HTML.
92
+ *
93
+ * The returned HTML is self-contained — footnote data is embedded as attributes:
94
+ * - `data-verse-footnote="KEY"` marks the footnote position
95
+ * - `data-verse-footnote-content="HTML"` contains the footnote's inner HTML
96
+ *
97
+ * Consumers can access verse context by walking up from a footnote anchor
98
+ * to `.closest('.yv-v[v]')`.
99
+ */
100
+ export type TransformedBibleHtml = {
101
+ /** The transformed HTML with footnotes replaced by marker elements */
102
+ html: string;
103
+ };
104
+
105
+ function wrapVerseContent(doc: Document): void {
106
+ function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void {
107
+ const children = Array.from(paragraph.childNodes);
108
+ if (children.length === 0) return;
109
+
110
+ const wrapper = doc.createElement('span');
111
+ wrapper.className = 'yv-v';
112
+ wrapper.setAttribute('v', verseNum);
113
+
114
+ const firstChild = children[0];
115
+ if (firstChild) {
116
+ paragraph.insertBefore(wrapper, firstChild);
117
+ }
118
+ children.forEach((child) => {
119
+ wrapper.appendChild(child);
120
+ });
121
+ }
122
+
123
+ function wrapParagraphsUntilBoundary(
124
+ doc: Document,
125
+ verseNum: string,
126
+ startParagraph: Element | null,
127
+ endParagraph?: Element | null,
128
+ ): void {
129
+ if (!startParagraph) return;
130
+
131
+ let currentParagraph: Element | null = startParagraph.nextElementSibling;
132
+
133
+ while (currentParagraph && currentParagraph !== endParagraph) {
134
+ const isHeading =
135
+ currentParagraph.classList.contains('yv-h') ||
136
+ currentParagraph.matches(
137
+ '.s1, .s2, .s3, .s4, .ms, .ms1, .ms2, .ms3, .ms4, .mr, .sp, .sr, .qa, .r',
138
+ );
139
+ if (isHeading) {
140
+ currentParagraph = currentParagraph.nextElementSibling;
141
+ continue;
142
+ }
143
+
144
+ if (currentParagraph.querySelector('.yv-v[v]')) break;
145
+
146
+ if (currentParagraph.classList.contains('p') || currentParagraph.tagName === 'P') {
147
+ wrapParagraphContent(doc, currentParagraph, verseNum);
148
+ }
149
+
150
+ currentParagraph = currentParagraph.nextElementSibling;
151
+ }
152
+ }
153
+
154
+ function handleParagraphWrapping(
155
+ doc: Document,
156
+ currentParagraph: Element | null,
157
+ nextParagraph: Element | null,
158
+ verseNum: string,
159
+ ): void {
160
+ if (!currentParagraph) return;
161
+
162
+ if (!nextParagraph) {
163
+ wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph);
164
+ return;
165
+ }
166
+
167
+ if (currentParagraph !== nextParagraph) {
168
+ wrapParagraphsUntilBoundary(doc, verseNum, currentParagraph, nextParagraph);
169
+ }
170
+ }
171
+
172
+ function processVerseMarker(marker: Element, index: number, markers: Element[]): void {
173
+ const verseNum = marker.getAttribute('v');
174
+ if (!verseNum) return;
175
+
176
+ const nextMarker = markers[index + 1];
177
+
178
+ const nodesToWrap = collectNodesBetweenMarkers(marker, nextMarker);
179
+ if (nodesToWrap.length === 0) return;
180
+
181
+ const currentParagraph = marker.closest('.p, p, div.p');
182
+ const nextParagraph = nextMarker?.closest('.p, p, div.p') || null;
183
+ const doc = marker.ownerDocument;
184
+
185
+ wrapNodesInVerse(marker, verseNum, nodesToWrap);
186
+ handleParagraphWrapping(doc, currentParagraph, nextParagraph, verseNum);
187
+ }
188
+
189
+ function wrapNodesInVerse(marker: Element, verseNum: string, nodes: Node[]): void {
190
+ const wrapper = marker.ownerDocument.createElement('span');
191
+ wrapper.className = 'yv-v';
192
+ wrapper.setAttribute('v', verseNum);
193
+
194
+ const firstNode = nodes[0];
195
+ if (firstNode) {
196
+ marker.parentNode?.insertBefore(wrapper, firstNode);
197
+ }
198
+
199
+ nodes.forEach((node) => {
200
+ wrapper.appendChild(node);
201
+ });
202
+ marker.remove();
203
+ }
204
+
205
+ function shouldStopCollecting(node: Node, endMarker: Element | undefined): boolean {
206
+ if (node === endMarker) return true;
207
+ if (endMarker && node.nodeType === 1 && (node as Element).contains(endMarker)) return true;
208
+ return false;
209
+ }
210
+
211
+ function shouldSkipNode(node: Node): boolean {
212
+ return node.nodeType === 1 && (node as Element).classList.contains('yv-h');
213
+ }
214
+
215
+ function collectNodesBetweenMarkers(
216
+ startMarker: Element,
217
+ endMarker: Element | undefined,
218
+ ): Node[] {
219
+ const nodes: Node[] = [];
220
+ let current: Node | null = startMarker.nextSibling;
221
+
222
+ while (current && !shouldStopCollecting(current, endMarker)) {
223
+ if (shouldSkipNode(current)) {
224
+ current = current.nextSibling;
225
+ continue;
226
+ }
227
+ nodes.push(current);
228
+ current = current.nextSibling;
229
+ }
230
+
231
+ return nodes;
232
+ }
233
+
234
+ const verseMarkers = Array.from(doc.querySelectorAll('.yv-v[v]'));
235
+ verseMarkers.forEach(processVerseMarker);
236
+ }
237
+
238
+ function assignFootnoteKeys(doc: Document): void {
239
+ let introIdx = 0;
240
+ doc.querySelectorAll('.yv-n.f').forEach((fn) => {
241
+ const verseNum = fn.closest('.yv-v[v]')?.getAttribute('v');
242
+ fn.setAttribute(FOOTNOTE_KEY_ATTR, verseNum ?? `intro-${introIdx++}`);
243
+ });
244
+ }
245
+
246
+ function replaceFootnotesWithAnchors(doc: Document, footnotes: Element[]): void {
247
+ for (const fn of footnotes) {
248
+ const key = fn.getAttribute(FOOTNOTE_KEY_ATTR);
249
+ if (!key) continue;
250
+
251
+ const prev = fn.previousSibling;
252
+ const next = fn.nextSibling;
253
+
254
+ const prevText = prev?.textContent ?? '';
255
+ const nextText = next?.textContent ?? '';
256
+
257
+ const prevNeedsSpace = prevText.length > 0 && !/\s$/.test(prevText);
258
+ const nextNeedsSpace = nextText.length > 0 && NEEDS_SPACE_BEFORE.test(nextText);
259
+
260
+ if (prevNeedsSpace && nextNeedsSpace && fn.parentNode) {
261
+ fn.parentNode.insertBefore(doc.createTextNode(' '), fn);
262
+ }
263
+
264
+ const anchor = doc.createElement('span');
265
+ anchor.setAttribute('data-verse-footnote', key);
266
+ anchor.setAttribute('data-verse-footnote-content', fn.innerHTML);
267
+ fn.replaceWith(anchor);
268
+ }
269
+ }
270
+
271
+ function addNbspToVerseLabels(doc: Document): void {
272
+ doc.querySelectorAll('.yv-vlbl').forEach((label) => {
273
+ const text = label.textContent || '';
274
+ if (!text.endsWith(NON_BREAKING_SPACE)) {
275
+ label.textContent = text + NON_BREAKING_SPACE;
276
+ }
277
+ });
278
+ }
279
+
280
+ function fixIrregularTables(doc: Document): void {
281
+ doc.querySelectorAll('table').forEach((table) => {
282
+ const rows = table.querySelectorAll('tr');
283
+ if (rows.length === 0) return;
284
+
285
+ let maxColumns = 0;
286
+ rows.forEach((row) => {
287
+ let count = 0;
288
+ row.querySelectorAll('td, th').forEach((cell) => {
289
+ count += parseInt(cell.getAttribute('colspan') || '1', 10);
290
+ });
291
+ maxColumns = Math.max(maxColumns, count);
292
+ });
293
+
294
+ if (maxColumns > 1) {
295
+ rows.forEach((row) => {
296
+ const cells = row.querySelectorAll('td, th');
297
+ if (cells.length === 1) {
298
+ const existing = parseInt(cells[0]!.getAttribute('colspan') || '1', 10);
299
+ if (existing < maxColumns) {
300
+ cells[0]!.setAttribute('colspan', maxColumns.toString());
301
+ }
302
+ }
303
+ });
304
+ }
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Transforms Bible HTML by cleaning up verse structure, extracting footnotes,
310
+ * and replacing them with self-contained anchor elements.
311
+ *
312
+ * Footnote data is embedded directly in the HTML via attributes:
313
+ * - `data-verse-footnote="KEY"` — the footnote key (verse number or `intro-N`)
314
+ * - `data-verse-footnote-content="HTML"` — the footnote's inner HTML content
315
+ *
316
+ * Verse context is available by walking up from a footnote anchor:
317
+ * `anchor.closest('.yv-v[v]')` returns the verse wrapper (null for intro footnotes).
318
+ *
319
+ * @param html - The raw Bible HTML from the YouVersion API
320
+ * @param options - DOM adapter options for parsing and serializing HTML
321
+ * @returns The transformed HTML
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * import { transformBibleHtml } from '@youversion/platform-core';
326
+ *
327
+ * const result = transformBibleHtml(rawHtml, {
328
+ * parseHtml: (html) => new DOMParser().parseFromString(html, 'text/html'),
329
+ * serializeHtml: (doc) => doc.body.innerHTML,
330
+ * });
331
+ *
332
+ * console.log(result.html); // Clean HTML with self-contained footnote anchors
333
+ * ```
334
+ */
335
+ export function transformBibleHtml(
336
+ html: string,
337
+ options: TransformBibleHtmlOptions,
338
+ ): TransformedBibleHtml {
339
+ const doc = options.parseHtml(html);
340
+
341
+ sanitizeBibleHtmlDocument(doc);
342
+ wrapVerseContent(doc);
343
+ assignFootnoteKeys(doc);
344
+
345
+ const footnotes = Array.from(doc.querySelectorAll('.yv-n.f'));
346
+ replaceFootnotesWithAnchors(doc, footnotes);
347
+
348
+ addNbspToVerseLabels(doc);
349
+ fixIrregularTables(doc);
350
+
351
+ const transformedHtml = options.serializeHtml(doc);
352
+ return { html: transformedHtml };
353
+ }
354
+
355
+ /**
356
+ * Transforms Bible HTML for browser environments using the native DOMParser API.
357
+ *
358
+ * @param html - The raw Bible HTML from the YouVersion API
359
+ * @returns The transformed HTML
360
+ *
361
+ * @example
362
+ * ```ts
363
+ * import { transformBibleHtmlForBrowser } from '@youversion/platform-core/browser';
364
+ *
365
+ * const result = transformBibleHtmlForBrowser(rawHtml);
366
+ * console.log(result.html); // Clean HTML with self-contained footnote anchors
367
+ * ```
368
+ */
369
+ export function transformBibleHtmlForBrowser(html: string): TransformedBibleHtml {
370
+ if (typeof globalThis.DOMParser === 'undefined') {
371
+ throw new Error('DOMParser is required to transform Bible HTML in browser environments');
372
+ }
373
+
374
+ return transformBibleHtml(html, {
375
+ parseHtml: (h) => new DOMParser().parseFromString(h, 'text/html'),
376
+ serializeHtml: (doc) => doc.body.innerHTML,
377
+ });
378
+ }
package/src/bible.ts CHANGED
@@ -235,12 +235,18 @@ export class BibleClient {
235
235
  /**
236
236
  * Fetches a passage (range of verses) from the Bible using the passages endpoint.
237
237
  * This is the new API format that returns HTML-formatted content.
238
+ *
239
+ * Note: The HTML returned from the API contains inline footnote content that should
240
+ * be transformed before rendering. Use `transformBibleHtml()` or
241
+ * `transformBibleHtmlForBrowser()` to clean up the HTML and extract footnotes.
242
+ *
238
243
  * @param versionId The version ID.
239
244
  * @param usfm The USFM reference (e.g., "JHN.3.1-2", "GEN.1", "JHN.3.16").
240
245
  * @param format The format to return ("html" or "text", default: "html").
241
246
  * @param include_headings Whether to include headings in the content.
242
247
  * @param include_notes Whether to include notes in the content.
243
248
  * @returns The requested BiblePassage object with HTML content.
249
+ *
244
250
  * @example
245
251
  * ```ts
246
252
  * // Get a single verse
@@ -251,6 +257,10 @@ export class BibleClient {
251
257
  *
252
258
  * // Get an entire chapter
253
259
  * const chapter = await bibleClient.getPassage(3034, "GEN.1");
260
+ *
261
+ * // Transform HTML before rendering
262
+ * const passage = await bibleClient.getPassage(3034, "JHN.3.16", "html", true, true);
263
+ * const transformed = transformBibleHtmlForBrowser(passage.content);
254
264
  * ```
255
265
  */
256
266
  async getPassage(
package/src/browser.ts ADDED
@@ -0,0 +1,4 @@
1
+ export {
2
+ transformBibleHtmlForBrowser as transformBibleHtml,
3
+ type TransformedBibleHtml,
4
+ } from './bible-html-transformer';
package/src/index.ts CHANGED
@@ -15,3 +15,8 @@ export * from './YouVersionPlatformConfiguration';
15
15
  export * from './types';
16
16
  export * from './utils/constants';
17
17
  export { getAdjacentChapter } from './getAdjacentChapter';
18
+ export {
19
+ transformBibleHtml,
20
+ type TransformBibleHtmlOptions,
21
+ type TransformedBibleHtml,
22
+ } from './bible-html-transformer';
@@ -1,11 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { BOOK_IDS } from '../utils/constants';
2
+ import { BOOK_IDS, CANON_IDS } from '../utils/constants';
3
3
  import { BibleChapterSchema } from './chapter';
4
4
 
5
- export const CanonSchema = z.enum(['old_testament', 'new_testament', 'deuterocanon']);
6
- export type Canon = Readonly<z.infer<typeof CanonSchema>>;
5
+ export const CanonSchema = z.enum(CANON_IDS);
7
6
 
8
- export const BibleBookIntroSchema = z.object({
7
+ const BibleBookIntroSchema = z.object({
9
8
  /** Intro identifier */
10
9
  id: z.string(),
11
10
  /** Intro passage identifier */
@@ -20,7 +19,6 @@ export const BookUsfmSchema = z.union([
20
19
  ...BOOK_IDS.map((id) => z.literal(id)),
21
20
  z.string().length(3) as z.ZodType<string & {}>,
22
21
  ]);
23
- export type BookUsfm = z.infer<typeof BookUsfmSchema>;
24
22
 
25
23
  export const BibleBookSchema = z.object({
26
24
  /** Book identifier (e.g., "MAT") */
package/src/server.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { transformBibleHtml } from './bible-html-transformer-server';
2
+ export { type TransformedBibleHtml } from './bible-html-transformer';
@@ -1,3 +1,8 @@
1
+ import type {
2
+ SignInWithYouVersionPermission,
3
+ SignInWithYouVersionResult,
4
+ } from '../SignInWithYouVersionResult';
5
+
1
6
  // Re-export all schema-derived types from schemas
2
7
  export type { BibleVersion } from '../schemas/version';
3
8
  export type { BibleBook, BibleBookIntro, CANON } from '../schemas/book';
@@ -16,11 +21,30 @@ export type { User } from '../schemas/user';
16
21
  export type { Highlight, CreateHighlight } from '../schemas/highlight';
17
22
  export type { Collection } from '../schemas/collection';
18
23
 
19
- // Re-export internal/non-API types
20
- export type { ApiConfig } from './api-config';
21
- export type {
22
- AuthenticationState,
23
- SignInWithYouVersionPermissionValues,
24
- AuthenticationScopes,
25
- } from './auth';
26
- export type { HighlightColor } from './highlight';
24
+ export interface ApiConfig {
25
+ apiHost?: string;
26
+ appKey: string;
27
+ timeout?: number;
28
+ installationId?: string;
29
+ redirectUri?: string;
30
+ }
31
+
32
+ export type SignInWithYouVersionPermissionValues =
33
+ (typeof SignInWithYouVersionPermission)[keyof typeof SignInWithYouVersionPermission];
34
+
35
+ export interface AuthenticationState {
36
+ readonly isAuthenticated: boolean;
37
+ readonly isLoading: boolean;
38
+ readonly accessToken: string | null;
39
+ readonly idToken: string | null;
40
+ readonly result: SignInWithYouVersionResult | null;
41
+ readonly error: Error | null;
42
+ }
43
+
44
+ export type AuthenticationScopes = 'profile' | 'email';
45
+
46
+ export interface HighlightColor {
47
+ id: number;
48
+ color: string;
49
+ label: string;
50
+ }
@@ -5,6 +5,8 @@
5
5
  */
6
6
  export const DEFAULT_LICENSE_FREE_BIBLE_VERSION = 3034 as const;
7
7
 
8
+ export const CANON_IDS = ['old_testament', 'new_testament', 'deuterocanon'] as const;
9
+
8
10
  export const BOOK_IDS = [
9
11
  'GEN',
10
12
  'EXO',
@@ -113,13 +115,13 @@ export const BOOK_IDS = [
113
115
  'LKA',
114
116
  ] as const;
115
117
 
116
- // Import types for BOOK_CANON - moved CANON_IDS to schemas/book.ts to avoid circular dependency
117
- import type { Canon, BookUsfm } from '../schemas/book';
118
+ type CanonId = (typeof CANON_IDS)[number];
119
+ type KnownBookUsfm = (typeof BOOK_IDS)[number];
118
120
 
119
121
  /**
120
122
  * @see https://github.com/youversion/usfm-references/blob/main/usfm_references/books.py
121
123
  */
122
- export const BOOK_CANON: Record<BookUsfm, Canon> = {
124
+ export const BOOK_CANON: Record<KnownBookUsfm, CanonId> = {
123
125
  GEN: 'old_testament',
124
126
  EXO: 'old_testament',
125
127
  LEV: 'old_testament',
@@ -1,53 +0,0 @@
1
- import { vi } from 'vitest';
2
-
3
- /**
4
- * Creates a mock YouVersionPlatformConfiguration for testing
5
- * Maintains internal state for access tokens and configuration
6
- */
7
- export const createMockPlatformConfiguration = (): {
8
- accessToken: string | null;
9
- idToken: string | null;
10
- refreshToken: string | null;
11
- appKey: string;
12
- apiHost: string;
13
- installationId: string | null;
14
- expiryDate: Date | null;
15
- clearAuthTokens: ReturnType<typeof vi.fn>;
16
- saveAuthData: ReturnType<typeof vi.fn>;
17
- } => {
18
- const config = {
19
- accessToken: null as string | null,
20
- idToken: null as string | null,
21
- refreshToken: null as string | null,
22
- appKey: '',
23
- apiHost: 'test-api.example.com',
24
- installationId: null as string | null,
25
- expiryDate: null as Date | null,
26
- clearAuthTokens: vi.fn(function (this: typeof config) {
27
- this.accessToken = null;
28
- this.idToken = null;
29
- this.refreshToken = null;
30
- this.expiryDate = null;
31
- }),
32
- saveAuthData: vi.fn(function (
33
- this: typeof config,
34
- accessToken: string | null,
35
- refreshToken: string | null,
36
- idToken: string | null,
37
- expiryDate?: Date | null,
38
- ) {
39
- this.accessToken = accessToken;
40
- this.refreshToken = refreshToken;
41
- this.idToken = idToken;
42
- if (expiryDate !== undefined) {
43
- this.expiryDate = expiryDate;
44
- }
45
- }),
46
- };
47
-
48
- // Bind methods to maintain context
49
- config.clearAuthTokens = config.clearAuthTokens.bind(config);
50
- config.saveAuthData = config.saveAuthData.bind(config);
51
-
52
- return config;
53
- };
@@ -1,93 +0,0 @@
1
- import { vi } from 'vitest';
2
-
3
- /**
4
- * Creates a mock JWT token for testing
5
- * @param payload The JWT payload to encode
6
- * @returns A mock JWT string with the format header.payload.signature
7
- */
8
- export const createMockJWT = (payload: Record<string, unknown>): string => {
9
- // Use a simple encoding for testing (not real base64)
10
- const header = 'mockHeader';
11
- const body = JSON.stringify(payload);
12
- const signature = 'mock-signature';
13
- return `${header}.${body}.${signature}`;
14
- };
15
-
16
- /**
17
- * Creates a realistic-looking JWT token with proper base64url encoding
18
- * @param payload The JWT payload
19
- * @returns A properly formatted JWT token
20
- */
21
- export const createRealisticJWT = (payload: Record<string, unknown>): string => {
22
- const header = { alg: 'HS256', typ: 'JWT' };
23
-
24
- // Simple base64url encoding for testing
25
- const base64urlEncode = (str: string) => {
26
- if (typeof btoa !== 'undefined') {
27
- return btoa(str).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
28
- }
29
- // Fallback for test environment
30
- return Buffer.from(str)
31
- .toString('base64')
32
- .replace(/=/g, '')
33
- .replace(/\+/g, '-')
34
- .replace(/\//g, '_');
35
- };
36
-
37
- const headerEncoded = base64urlEncode(JSON.stringify(header));
38
- const payloadEncoded = base64urlEncode(JSON.stringify(payload));
39
- const signature = 'invalid-signature'; // Mock signature
40
-
41
- return `${headerEncoded}.${payloadEncoded}.${signature}`;
42
- };
43
-
44
- /**
45
- * Sets up JWT-related mocks for atob/btoa functions
46
- * @param payload The expected JWT payload to decode
47
- */
48
- export const setupJWTMocks = (
49
- payload: Record<string, unknown> = {},
50
- ): { atob: ReturnType<typeof vi.fn>; btoa: ReturnType<typeof vi.fn> } => {
51
- const atobMock = vi.fn((str: string) => {
52
- // Handle base64 padding
53
- let padded = str;
54
- while (padded.length % 4 !== 0) {
55
- padded += '=';
56
- }
57
-
58
- // For testing, just return the payload
59
- if (str.includes('eyJ')) {
60
- // Looks like a real base64 JWT part
61
- return JSON.stringify(payload);
62
- }
63
-
64
- return str;
65
- });
66
-
67
- const btoaMock = vi.fn((str: string) => {
68
- // Simple base64 encoding for testing
69
- return `base64_${str.length}`;
70
- });
71
-
72
- vi.stubGlobal('atob', atobMock);
73
- vi.stubGlobal('btoa', btoaMock);
74
-
75
- return { atob: atobMock, btoa: btoaMock };
76
- };
77
-
78
- /**
79
- * Creates a mock JWT with standard claims
80
- * @param overrides Optional claim overrides
81
- */
82
- export const createMockJWTWithClaims = (overrides: Record<string, unknown> = {}): string => {
83
- const defaultClaims = {
84
- sub: '1234567890',
85
- name: 'John Doe',
86
- email: 'john@example.com',
87
- profile_picture: 'https://example.com/avatar.jpg',
88
- iat: 1516239022,
89
- exp: 1516242622,
90
- };
91
-
92
- return createRealisticJWT({ ...defaultClaims, ...overrides });
93
- };