@youversion/platform-react-hooks 0.4.1

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.
Files changed (181) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +9 -0
  3. package/LICENSE +201 -0
  4. package/dist/context/BibleSDKContext.d.ts +6 -0
  5. package/dist/context/BibleSDKContext.d.ts.map +1 -0
  6. package/dist/context/BibleSDKContext.js +4 -0
  7. package/dist/context/BibleSDKContext.js.map +1 -0
  8. package/dist/context/BibleSDKProvider.d.ts +8 -0
  9. package/dist/context/BibleSDKProvider.d.ts.map +1 -0
  10. package/dist/context/BibleSDKProvider.js +7 -0
  11. package/dist/context/BibleSDKProvider.js.map +1 -0
  12. package/dist/context/ReaderContext.d.ts +15 -0
  13. package/dist/context/ReaderContext.d.ts.map +1 -0
  14. package/dist/context/ReaderContext.js +11 -0
  15. package/dist/context/ReaderContext.js.map +1 -0
  16. package/dist/context/ReaderProvider.d.ts +11 -0
  17. package/dist/context/ReaderProvider.d.ts.map +1 -0
  18. package/dist/context/ReaderProvider.js +21 -0
  19. package/dist/context/ReaderProvider.js.map +1 -0
  20. package/dist/context/VerseSelectionContext.d.ts +9 -0
  21. package/dist/context/VerseSelectionContext.d.ts.map +1 -0
  22. package/dist/context/VerseSelectionContext.js +3 -0
  23. package/dist/context/VerseSelectionContext.js.map +1 -0
  24. package/dist/context/VerseSelectionProvider.d.ts +3 -0
  25. package/dist/context/VerseSelectionProvider.d.ts.map +1 -0
  26. package/dist/context/VerseSelectionProvider.js +33 -0
  27. package/dist/context/VerseSelectionProvider.js.map +1 -0
  28. package/dist/context/index.d.ts +7 -0
  29. package/dist/context/index.d.ts.map +1 -0
  30. package/dist/context/index.js +7 -0
  31. package/dist/context/index.js.map +1 -0
  32. package/dist/index.d.ts +21 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +21 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/test/setup.d.ts +2 -0
  37. package/dist/test/setup.d.ts.map +1 -0
  38. package/dist/test/setup.js +9 -0
  39. package/dist/test/setup.js.map +1 -0
  40. package/dist/useApiData.d.ts +12 -0
  41. package/dist/useApiData.d.ts.map +1 -0
  42. package/dist/useApiData.js +46 -0
  43. package/dist/useApiData.js.map +1 -0
  44. package/dist/useBibleClient.d.ts +3 -0
  45. package/dist/useBibleClient.d.ts.map +1 -0
  46. package/dist/useBibleClient.js +17 -0
  47. package/dist/useBibleClient.js.map +1 -0
  48. package/dist/useBook.d.ts +9 -0
  49. package/dist/useBook.d.ts.map +1 -0
  50. package/dist/useBook.js +11 -0
  51. package/dist/useBook.js.map +1 -0
  52. package/dist/useBooks.d.ts +9 -0
  53. package/dist/useBooks.d.ts.map +1 -0
  54. package/dist/useBooks.js +11 -0
  55. package/dist/useBooks.js.map +1 -0
  56. package/dist/useChapter.d.ts +9 -0
  57. package/dist/useChapter.d.ts.map +1 -0
  58. package/dist/useChapter.js +11 -0
  59. package/dist/useChapter.js.map +1 -0
  60. package/dist/useChapterNavigation.d.ts +23 -0
  61. package/dist/useChapterNavigation.d.ts.map +1 -0
  62. package/dist/useChapterNavigation.js +52 -0
  63. package/dist/useChapterNavigation.js.map +1 -0
  64. package/dist/useChapters.d.ts +9 -0
  65. package/dist/useChapters.d.ts.map +1 -0
  66. package/dist/useChapters.js +13 -0
  67. package/dist/useChapters.js.map +1 -0
  68. package/dist/useFilteredVersions.d.ts +6 -0
  69. package/dist/useFilteredVersions.d.ts.map +1 -0
  70. package/dist/useFilteredVersions.js +24 -0
  71. package/dist/useFilteredVersions.js.map +1 -0
  72. package/dist/useHighlights.d.ts +11 -0
  73. package/dist/useHighlights.d.ts.map +1 -0
  74. package/dist/useHighlights.js +40 -0
  75. package/dist/useHighlights.js.map +1 -0
  76. package/dist/useInitData.d.ts +23 -0
  77. package/dist/useInitData.d.ts.map +1 -0
  78. package/dist/useInitData.js +24 -0
  79. package/dist/useInitData.js.map +1 -0
  80. package/dist/useLanguages.d.ts +9 -0
  81. package/dist/useLanguages.d.ts.map +1 -0
  82. package/dist/useLanguages.js +29 -0
  83. package/dist/useLanguages.js.map +1 -0
  84. package/dist/usePassage.d.ts +18 -0
  85. package/dist/usePassage.d.ts.map +1 -0
  86. package/dist/usePassage.js +11 -0
  87. package/dist/usePassage.js.map +1 -0
  88. package/dist/useVOTD.d.ts +9 -0
  89. package/dist/useVOTD.d.ts.map +1 -0
  90. package/dist/useVOTD.js +9 -0
  91. package/dist/useVOTD.js.map +1 -0
  92. package/dist/useVerse.d.ts +9 -0
  93. package/dist/useVerse.d.ts.map +1 -0
  94. package/dist/useVerse.js +11 -0
  95. package/dist/useVerse.js.map +1 -0
  96. package/dist/useVerseSelection.d.ts +3 -0
  97. package/dist/useVerseSelection.d.ts.map +1 -0
  98. package/dist/useVerseSelection.js +10 -0
  99. package/dist/useVerseSelection.js.map +1 -0
  100. package/dist/useVerses.d.ts +9 -0
  101. package/dist/useVerses.d.ts.map +1 -0
  102. package/dist/useVerses.js +11 -0
  103. package/dist/useVerses.js.map +1 -0
  104. package/dist/useVersion.d.ts +9 -0
  105. package/dist/useVersion.d.ts.map +1 -0
  106. package/dist/useVersion.js +11 -0
  107. package/dist/useVersion.js.map +1 -0
  108. package/dist/useVersions.d.ts +9 -0
  109. package/dist/useVersions.d.ts.map +1 -0
  110. package/dist/useVersions.js +11 -0
  111. package/dist/useVersions.js.map +1 -0
  112. package/dist/utility/extractTextFromHTML.d.ts +9 -0
  113. package/dist/utility/extractTextFromHTML.d.ts.map +1 -0
  114. package/dist/utility/extractTextFromHTML.js +21 -0
  115. package/dist/utility/extractTextFromHTML.js.map +1 -0
  116. package/dist/utility/extractVersesFromHTML.d.ts +9 -0
  117. package/dist/utility/extractVersesFromHTML.d.ts.map +1 -0
  118. package/dist/utility/extractVersesFromHTML.js +26 -0
  119. package/dist/utility/extractVersesFromHTML.js.map +1 -0
  120. package/dist/utility/getDayOfYear.d.ts +2 -0
  121. package/dist/utility/getDayOfYear.d.ts.map +1 -0
  122. package/dist/utility/getDayOfYear.js +5 -0
  123. package/dist/utility/getDayOfYear.js.map +1 -0
  124. package/dist/utility/index.d.ts +6 -0
  125. package/dist/utility/index.d.ts.map +1 -0
  126. package/dist/utility/index.js +6 -0
  127. package/dist/utility/index.js.map +1 -0
  128. package/dist/utility/useDebounce.d.ts +14 -0
  129. package/dist/utility/useDebounce.d.ts.map +1 -0
  130. package/dist/utility/useDebounce.js +24 -0
  131. package/dist/utility/useDebounce.js.map +1 -0
  132. package/dist/utility/version.d.ts +3 -0
  133. package/dist/utility/version.d.ts.map +1 -0
  134. package/dist/utility/version.js +4 -0
  135. package/dist/utility/version.js.map +1 -0
  136. package/package.json +50 -0
  137. package/src/context/BibleSDKContext.tsx +9 -0
  138. package/src/context/BibleSDKProvider.tsx +16 -0
  139. package/src/context/ReaderContext.tsx +27 -0
  140. package/src/context/ReaderProvider.tsx +36 -0
  141. package/src/context/VerseSelectionContext.tsx +11 -0
  142. package/src/context/VerseSelectionProvider.tsx +39 -0
  143. package/src/context/index.ts +6 -0
  144. package/src/index.ts +20 -0
  145. package/src/test/setup.ts +9 -0
  146. package/src/useApiData.ts +71 -0
  147. package/src/useBibleClient.test.tsx +151 -0
  148. package/src/useBibleClient.ts +22 -0
  149. package/src/useBook.ts +28 -0
  150. package/src/useBooks.ts +31 -0
  151. package/src/useChapter.ts +33 -0
  152. package/src/useChapterNavigation.ts +77 -0
  153. package/src/useChapters.ts +36 -0
  154. package/src/useFilteredVersions.test.tsx +248 -0
  155. package/src/useFilteredVersions.ts +38 -0
  156. package/src/useHighlights.test.tsx +448 -0
  157. package/src/useHighlights.ts +80 -0
  158. package/src/useInitData.ts +54 -0
  159. package/src/useLanguages.test.tsx +296 -0
  160. package/src/useLanguages.ts +57 -0
  161. package/src/usePassage.ts +48 -0
  162. package/src/useVOTD.test.tsx +269 -0
  163. package/src/useVOTD.ts +19 -0
  164. package/src/useVerse.ts +35 -0
  165. package/src/useVerseSelection.ts +13 -0
  166. package/src/useVerses.ts +34 -0
  167. package/src/useVersion.ts +28 -0
  168. package/src/useVersions.ts +33 -0
  169. package/src/utility/extractTextFromHTML.test.ts +112 -0
  170. package/src/utility/extractTextFromHTML.ts +22 -0
  171. package/src/utility/extractVersesFromHTML.test.ts +186 -0
  172. package/src/utility/extractVersesFromHTML.ts +31 -0
  173. package/src/utility/getDayOfYear.ts +6 -0
  174. package/src/utility/index.ts +5 -0
  175. package/src/utility/useDebounce.test.tsx +95 -0
  176. package/src/utility/useDebounce.ts +27 -0
  177. package/src/utility/version.ts +5 -0
  178. package/tsconfig.build.json +8 -0
  179. package/tsconfig.json +13 -0
  180. package/vitest.config.ts +11 -0
  181. package/vitest.setup.ts +1 -0
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useBibleClient } from './useBibleClient';
4
+ import { useApiData, type UseApiDataOptions } from './useApiData';
5
+ import type { BibleVerse } from '@youversion/platform-core';
6
+
7
+ export function useVerse(
8
+ versionId: number,
9
+ book: string,
10
+ chapter: number,
11
+ verse: number,
12
+ options?: UseApiDataOptions,
13
+ ): {
14
+ verse: BibleVerse | null;
15
+ loading: boolean;
16
+ error: Error | null;
17
+ refetch: () => void;
18
+ } {
19
+ const bibleClient = useBibleClient();
20
+
21
+ const {
22
+ data: verseData,
23
+ loading,
24
+ error,
25
+ refetch,
26
+ } = useApiData<BibleVerse>(
27
+ () => bibleClient.getVerse(versionId, book, chapter, verse),
28
+ [bibleClient, versionId, book, chapter, verse],
29
+ {
30
+ enabled: options?.enabled !== false,
31
+ },
32
+ );
33
+
34
+ return { verse: verseData, loading, error, refetch };
35
+ }
@@ -0,0 +1,13 @@
1
+ import { useContext } from 'react';
2
+ import {
3
+ VerseSelectionContext,
4
+ type VerseSelectionContextData,
5
+ } from './context/VerseSelectionContext';
6
+
7
+ export function useVerseSelection(): VerseSelectionContextData {
8
+ const context = useContext(VerseSelectionContext);
9
+ if (!context) {
10
+ throw new Error('useVerseSelection must be used within a VerseSelectionProvider');
11
+ }
12
+ return context;
13
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { useBibleClient } from './useBibleClient';
4
+ import { useApiData, type UseApiDataOptions } from './useApiData';
5
+ import type { BibleVerse, Collection } from '@youversion/platform-core';
6
+
7
+ export function useVerses(
8
+ versionId: number,
9
+ book: string,
10
+ chapter: number,
11
+ options?: UseApiDataOptions,
12
+ ): {
13
+ verses: Collection<BibleVerse> | null;
14
+ loading: boolean;
15
+ error: Error | null;
16
+ refetch: () => void;
17
+ } {
18
+ const bibleClient = useBibleClient();
19
+
20
+ const {
21
+ data: verses,
22
+ loading,
23
+ error,
24
+ refetch,
25
+ } = useApiData<Collection<BibleVerse>>(
26
+ () => bibleClient.getVerses(versionId, book, chapter),
27
+ [bibleClient, versionId, book, chapter],
28
+ {
29
+ enabled: options?.enabled !== false,
30
+ },
31
+ );
32
+
33
+ return { verses, loading, error, refetch };
34
+ }
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { useBibleClient } from './useBibleClient';
4
+ import { useApiData, type UseApiDataOptions } from './useApiData';
5
+ import type { BibleVersion } from '@youversion/platform-core';
6
+
7
+ export function useVersion(
8
+ versionId: number,
9
+ options?: UseApiDataOptions,
10
+ ): {
11
+ version: BibleVersion | null;
12
+ loading: boolean;
13
+ error: Error | null;
14
+ refetch: () => void;
15
+ } {
16
+ const bibleClient = useBibleClient();
17
+
18
+ const {
19
+ data: version,
20
+ loading,
21
+ error,
22
+ refetch,
23
+ } = useApiData<BibleVersion>(() => bibleClient.getVersion(versionId), [bibleClient, versionId], {
24
+ enabled: options?.enabled !== false,
25
+ });
26
+
27
+ return { version, loading, error, refetch };
28
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useBibleClient } from './useBibleClient';
4
+ import { useApiData, type UseApiDataOptions } from './useApiData';
5
+ import type { Collection, BibleVersion } from '@youversion/platform-core';
6
+
7
+ export function useVersions(
8
+ languageRanges: string = 'en',
9
+ licenseId?: string | number,
10
+ options?: UseApiDataOptions,
11
+ ): {
12
+ versions: Collection<BibleVersion> | null;
13
+ loading: boolean;
14
+ error: Error | null;
15
+ refetch: () => void;
16
+ } {
17
+ const bibleClient = useBibleClient();
18
+
19
+ const {
20
+ data: versions,
21
+ loading,
22
+ error,
23
+ refetch,
24
+ } = useApiData<Collection<BibleVersion>>(
25
+ () => bibleClient.getVersions(languageRanges, licenseId),
26
+ [bibleClient, languageRanges, licenseId],
27
+ {
28
+ enabled: options?.enabled !== false,
29
+ },
30
+ );
31
+
32
+ return { versions, loading, error, refetch };
33
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractTextFromHtml } from './extractTextFromHTML';
3
+
4
+ describe('extractTextFromHtml', () => {
5
+ it('should return empty string for empty input', () => {
6
+ expect(extractTextFromHtml('')).toBe('');
7
+ });
8
+
9
+ it('should return empty string for falsy input', () => {
10
+ expect(extractTextFromHtml(null as unknown as string)).toBe('');
11
+ expect(extractTextFromHtml(undefined as unknown as string)).toBe('');
12
+ });
13
+
14
+ it('should extract text from single content span', () => {
15
+ const html = '<span class="content">Hello World</span>';
16
+ expect(extractTextFromHtml(html)).toBe('Hello World');
17
+ });
18
+
19
+ it('should extract text from multiple content spans', () => {
20
+ const html = `
21
+ <span class="content">In the</span>
22
+ <span class="content">beginning</span>
23
+ <span class="content">God</span>
24
+ `;
25
+ expect(extractTextFromHtml(html)).toBe('In the beginning God');
26
+ });
27
+
28
+ it('should ignore non-content spans', () => {
29
+ const html = `
30
+ <span class="label">1</span>
31
+ <span class="content">In the beginning</span>
32
+ <span class="footnote">*</span>
33
+ <span class="content">God created</span>
34
+ `;
35
+ expect(extractTextFromHtml(html)).toBe('In the beginning God created');
36
+ });
37
+
38
+ it('should normalize whitespace', () => {
39
+ const html = `
40
+ <span class="content"> In the </span>
41
+ <span class="content"> beginning </span>
42
+ `;
43
+ expect(extractTextFromHtml(html)).toBe('In the beginning');
44
+ });
45
+
46
+ it('should handle nested elements within content spans', () => {
47
+ const html = `
48
+ <span class="content">The <em>word</em> of God</span>
49
+ <span class="content">is <strong>powerful</strong></span>
50
+ `;
51
+ expect(extractTextFromHtml(html)).toBe('The word of God is powerful');
52
+ });
53
+
54
+ it('should filter out empty content spans', () => {
55
+ const html = `
56
+ <span class="content">Hello</span>
57
+ <span class="content"> </span>
58
+ <span class="content"></span>
59
+ <span class="content">World</span>
60
+ `;
61
+ expect(extractTextFromHtml(html)).toBe('Hello World');
62
+ });
63
+
64
+ it('should handle complex verse markup', () => {
65
+ const html = `
66
+ <span class="label">1</span>
67
+ <span class="content">In the beginning</span>
68
+ <span class="content">God created the heavens and the earth.</span>
69
+ <span class="footnote">a</span>
70
+ <span class="label">2</span>
71
+ <span class="content">Now the earth was formless and empty,</span>
72
+ `;
73
+ expect(extractTextFromHtml(html)).toBe(
74
+ 'In the beginning God created the heavens and the earth. Now the earth was formless and empty,',
75
+ );
76
+ });
77
+
78
+ it('should handle HTML with no content spans', () => {
79
+ const html = `
80
+ <div>Some text</div>
81
+ <span class="label">1</span>
82
+ <span class="footnote">*</span>
83
+ `;
84
+ expect(extractTextFromHtml(html)).toBe('');
85
+ });
86
+
87
+ it('should preserve order of content spans', () => {
88
+ const html = `
89
+ <span class="content">First</span>
90
+ <span class="other">ignored</span>
91
+ <span class="content">Second</span>
92
+ <span class="content">Third</span>
93
+ `;
94
+ expect(extractTextFromHtml(html)).toBe('First Second Third');
95
+ });
96
+
97
+ it('should handle special characters and entities', () => {
98
+ const html = `
99
+ <span class="content">God's love</span>
100
+ <span class="content">&amp; mercy</span>
101
+ `;
102
+ expect(extractTextFromHtml(html)).toBe("God's love & mercy");
103
+ });
104
+
105
+ it('should handle line breaks and tabs in content', () => {
106
+ const html = `
107
+ <span class="content">Line one\nLine two</span>
108
+ <span class="content">Tab\there</span>
109
+ `;
110
+ expect(extractTextFromHtml(html)).toBe('Line one Line two Tab here');
111
+ });
112
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Extracts readable text from an HTML string, focusing on content spans.
3
+ * This is needed because verses include markup for labels, footnotes, etc.
4
+ *
5
+ * @param html - The HTML content to extract text from
6
+ * @returns Plain text with normalized whitespace
7
+ */
8
+ export function extractTextFromHtml(html: string): string {
9
+ if (!html) return '';
10
+
11
+ const container = document.createElement('div');
12
+ container.innerHTML = html;
13
+
14
+ // Extract text from content spans and join with spaces
15
+ const contentText = Array.from(container.querySelectorAll<HTMLElement>('span.content'))
16
+ .map((el) => (el.textContent || '').trim())
17
+ .filter(Boolean)
18
+ .join(' ');
19
+
20
+ // Normalize whitespace
21
+ return contentText.replace(/\s+/g, ' ').trim();
22
+ }
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractVersesFromHTML } from './extractVersesFromHTML';
3
+
4
+ describe('extractVersesFromHTML', () => {
5
+ it('should return empty array for null input', () => {
6
+ expect(extractVersesFromHTML(null)).toEqual([]);
7
+ });
8
+
9
+ it('should return empty array for undefined input', () => {
10
+ expect(extractVersesFromHTML(undefined)).toEqual([]);
11
+ });
12
+
13
+ it('should return empty array for empty string', () => {
14
+ expect(extractVersesFromHTML('')).toEqual([]);
15
+ });
16
+
17
+ it('should extract single verse from HTML', () => {
18
+ const html = `
19
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
20
+ <span class="content">In the beginning God created the heavens and the earth.</span>
21
+ `;
22
+
23
+ const result = extractVersesFromHTML(html);
24
+
25
+ expect(result).toHaveLength(1);
26
+ expect(result[0]).toEqual({
27
+ verse: 1,
28
+ html: expect.stringContaining('<span class="yv-v" v="1">') as string,
29
+ });
30
+ expect(result[0]?.html).toContain('In the beginning God created');
31
+ });
32
+
33
+ it('should extract multiple verses from HTML', () => {
34
+ const html = `
35
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
36
+ <span class="content">In the beginning God created the heavens and the earth.</span>
37
+ <span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>
38
+ <span class="content">Now the earth was formless and empty.</span>
39
+ <span class="yv-v" v="3"></span><span class="yv-vlbl">3</span>
40
+ <span class="content">And God said, "Let there be light," and there was light.</span>
41
+ `;
42
+
43
+ const result = extractVersesFromHTML(html);
44
+
45
+ expect(result).toHaveLength(3);
46
+ expect(result[0]).toEqual({
47
+ verse: 1,
48
+ html: expect.stringContaining('In the beginning') as string,
49
+ });
50
+ expect(result[1]).toEqual({
51
+ verse: 2,
52
+ html: expect.stringContaining('formless and empty') as string,
53
+ });
54
+ expect(result[2]).toEqual({
55
+ verse: 3,
56
+ html: expect.stringContaining('Let there be light') as string,
57
+ });
58
+ });
59
+
60
+ it('should handle verses with double-digit numbers', () => {
61
+ const html = `
62
+ <span class="yv-v" v="10"></span><span class="yv-vlbl">10</span>
63
+ <span class="content">Verse ten content.</span>
64
+ <span class="yv-v" v="25"></span><span class="yv-vlbl">25</span>
65
+ <span class="content">Verse twenty-five content.</span>
66
+ <span class="yv-v" v="100"></span><span class="yv-vlbl">100</span>
67
+ <span class="content">Verse one hundred content.</span>
68
+ `;
69
+
70
+ const result = extractVersesFromHTML(html);
71
+
72
+ expect(result).toHaveLength(3);
73
+ expect(result[0]?.verse).toBe(10);
74
+ expect(result[1]?.verse).toBe(25);
75
+ expect(result[2]?.verse).toBe(100);
76
+ });
77
+
78
+ it('should preserve HTML formatting within verses', () => {
79
+ const html = `
80
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
81
+ <span class="content">This has <strong>bold</strong> and <em>italic</em> text.</span>
82
+ <sup class="footnote">a</sup>
83
+ `;
84
+
85
+ const result = extractVersesFromHTML(html);
86
+
87
+ expect(result).toHaveLength(1);
88
+ expect(result[0]?.html).toContain('<strong>bold</strong>');
89
+ expect(result[0]?.html).toContain('<em>italic</em>');
90
+ expect(result[0]?.html).toContain('<sup class="footnote">a</sup>');
91
+ });
92
+
93
+ it('should handle verses with footnotes and cross-references', () => {
94
+ const html = `
95
+ <span class="yv-v" v="5"></span><span class="yv-vlbl">5</span>
96
+ <span class="content">God called the light "day,"</span>
97
+ <sup class="footnote">a</sup>
98
+ <span class="content">and the darkness he called "night."</span>
99
+ <sup class="crossref">b</sup>
100
+ `;
101
+
102
+ const result = extractVersesFromHTML(html);
103
+
104
+ expect(result).toHaveLength(1);
105
+ expect(result[0]?.verse).toBe(5);
106
+ expect(result[0]?.html).toContain('God called the light');
107
+ expect(result[0]?.html).toContain('footnote');
108
+ expect(result[0]?.html).toContain('crossref');
109
+ });
110
+
111
+ it('should handle HTML with no verse markers', () => {
112
+ const html = `
113
+ <div>Some content without verse markers</div>
114
+ <span class="content">Just regular text</span>
115
+ `;
116
+
117
+ const result = extractVersesFromHTML(html);
118
+
119
+ expect(result).toEqual([]);
120
+ });
121
+
122
+ it('should handle malformed verse markers', () => {
123
+ const html = `
124
+ <span class="yv-v" v="1"></span>
125
+ <span class="content">Missing verse label span</span>
126
+ <span class="yv-vlbl">2</span>
127
+ <span class="content">Missing verse marker span</span>
128
+ `;
129
+
130
+ const result = extractVersesFromHTML(html);
131
+
132
+ expect(result).toEqual([]);
133
+ });
134
+
135
+ it('should trim whitespace from extracted HTML', () => {
136
+ const html = `
137
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
138
+
139
+
140
+ <span class="content">Content with surrounding whitespace</span>
141
+
142
+
143
+ `;
144
+
145
+ const result = extractVersesFromHTML(html);
146
+
147
+ expect(result).toHaveLength(1);
148
+ expect(result[0]?.html).not.toMatch(/^\s+/);
149
+ expect(result[0]?.html).not.toMatch(/\s+$/);
150
+ });
151
+
152
+ it('should handle consecutive verses without content between them', () => {
153
+ const html = `
154
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
155
+ <span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>
156
+ <span class="content">Verse 2 content</span>
157
+ `;
158
+
159
+ const result = extractVersesFromHTML(html);
160
+
161
+ expect(result).toHaveLength(2);
162
+ expect(result[0]).toEqual({
163
+ verse: 1,
164
+ html: '<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>',
165
+ });
166
+ expect(result[1]?.html).toContain('Verse 2 content');
167
+ });
168
+
169
+ it('should extract verses in correct order', () => {
170
+ const html = `
171
+ <span class="yv-v" v="3"></span><span class="yv-vlbl">3</span>
172
+ <span class="content">Third verse</span>
173
+ <span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>
174
+ <span class="content">First verse</span>
175
+ <span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>
176
+ <span class="content">Second verse</span>
177
+ `;
178
+
179
+ const result = extractVersesFromHTML(html);
180
+
181
+ expect(result).toHaveLength(3);
182
+ expect(result[0]?.verse).toBe(3);
183
+ expect(result[1]?.verse).toBe(1);
184
+ expect(result[2]?.verse).toBe(2);
185
+ });
186
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Splits a passage HTML into an array of verse HTML strings.
3
+ * Expects markers like: <span class="yv-v" v="N"></span><span class="yv-vlbl">N</span>
4
+ */
5
+ export function extractVersesFromHTML(
6
+ html: string | null | undefined,
7
+ ): { verse: number; html: string }[] {
8
+ if (!html) return [];
9
+
10
+ const results: { verse: number; html: string }[] = [];
11
+ const pattern = /<span class="yv-v" v="(\d+)"><\/span><span class="yv-vlbl">\d+<\/span>/g;
12
+
13
+ let execResult: RegExpExecArray | null;
14
+ const indices: { verse: number; index: number }[] = [];
15
+
16
+ while ((execResult = pattern.exec(html)) !== null) {
17
+ const verseNumString = execResult[1]!;
18
+ indices.push({ verse: parseInt(verseNumString, 10), index: execResult.index });
19
+ }
20
+
21
+ for (let i = 0; i < indices.length; i++) {
22
+ const current = indices[i]!;
23
+ const next = indices[i + 1];
24
+ const start = current.index;
25
+ const end = next ? next.index : html.length;
26
+ const slice = html.slice(start, end).trim();
27
+ results.push({ verse: current.verse, html: slice });
28
+ }
29
+
30
+ return results;
31
+ }
@@ -0,0 +1,6 @@
1
+ export function getDayOfYear(date: Date): number {
2
+ const dayOfYear = Math.floor(
3
+ (date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 86400000,
4
+ );
5
+ return dayOfYear;
6
+ }
@@ -0,0 +1,5 @@
1
+ export * from './extractTextFromHTML';
2
+ export * from './useDebounce';
3
+ export * from './version';
4
+ export * from './extractVersesFromHTML';
5
+ export * from './getDayOfYear';
@@ -0,0 +1,95 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest';
3
+ import { useDebounce } from './useDebounce';
4
+
5
+ describe('useDebounce', () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.runOnlyPendingTimers();
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it('should return initial value immediately', () => {
16
+ const { result } = renderHook(() => useDebounce('initial', 500));
17
+
18
+ expect(result.current).toBe('initial');
19
+ });
20
+
21
+ it('should debounce value changes', () => {
22
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
23
+ initialProps: { value: 'initial', delay: 500 },
24
+ });
25
+
26
+ expect(result.current).toBe('initial');
27
+
28
+ rerender({ value: 'updated', delay: 500 });
29
+
30
+ expect(result.current).toBe('initial');
31
+
32
+ act(() => {
33
+ vi.advanceTimersByTime(500);
34
+ });
35
+
36
+ expect(result.current).toBe('updated');
37
+ });
38
+
39
+ it('should cancel previous timeout on rapid changes', () => {
40
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
41
+ initialProps: { value: 'first', delay: 500 },
42
+ });
43
+
44
+ expect(result.current).toBe('first');
45
+
46
+ rerender({ value: 'second', delay: 500 });
47
+
48
+ act(() => {
49
+ vi.advanceTimersByTime(300);
50
+ });
51
+
52
+ expect(result.current).toBe('first');
53
+
54
+ rerender({ value: 'third', delay: 500 });
55
+
56
+ act(() => {
57
+ vi.advanceTimersByTime(300);
58
+ });
59
+
60
+ expect(result.current).toBe('first');
61
+
62
+ act(() => {
63
+ vi.advanceTimersByTime(200);
64
+ });
65
+
66
+ expect(result.current).toBe('third');
67
+ });
68
+
69
+ it('should handle different data types', () => {
70
+ const { result: numberResult } = renderHook(() => useDebounce(42, 100));
71
+ expect(numberResult.current).toBe(42);
72
+
73
+ const { result: objectResult } = renderHook(() => useDebounce({ key: 'value' }, 100));
74
+ expect(objectResult.current).toEqual({ key: 'value' });
75
+
76
+ const { result: arrayResult } = renderHook(() => useDebounce([1, 2, 3], 100));
77
+ expect(arrayResult.current).toEqual([1, 2, 3]);
78
+ });
79
+
80
+ it('should update immediately when delay is 0', () => {
81
+ const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
82
+ initialProps: { value: 'initial', delay: 0 },
83
+ });
84
+
85
+ expect(result.current).toBe('initial');
86
+
87
+ rerender({ value: 'updated', delay: 0 });
88
+
89
+ act(() => {
90
+ vi.runAllTimers();
91
+ });
92
+
93
+ expect(result.current).toBe('updated');
94
+ });
95
+ });
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Custom hook for debouncing a value.
5
+ *
6
+ * The hook delays the update of the given value until a specified delay has passed
7
+ * after the last change. It is commonly used to optimize the performance of
8
+ * functions that depend on user input or rapidly updating values.
9
+ *
10
+ * @param value - The input value to be debounced.
11
+ * @param delay - The duration in milliseconds to delay the update of the value.
12
+ *
13
+ * @returns The debounced value, updated after the specified delay.
14
+ */
15
+ export function useDebounce<T>(value: T, delay: number): T {
16
+ const [debouncedValue, setDebouncedValue] = useState(value);
17
+
18
+ useEffect(() => {
19
+ const handler = setTimeout(() => {
20
+ setDebouncedValue(value);
21
+ }, delay);
22
+
23
+ return () => clearTimeout(handler);
24
+ }, [value, delay]);
25
+
26
+ return debouncedValue;
27
+ }
@@ -0,0 +1,5 @@
1
+ import type { BibleVersion } from '@youversion/platform-core';
2
+
3
+ export function getISOFromVersion(version: BibleVersion): string {
4
+ return version?.language_tag || 'unknown';
5
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src"
5
+ },
6
+ "include": ["src"],
7
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "@internal/tsconfig/react.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": ".",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@/*": ["./src/*"]
9
+ }
10
+ },
11
+ "include": ["src/**/*", "vitest.setup.ts"],
12
+ "exclude": ["node_modules", "dist"]
13
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ environment: 'jsdom',
8
+ setupFiles: ['./src/test/setup.ts'],
9
+ globals: true,
10
+ },
11
+ });