@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.
- package/.turbo/turbo-build.log +21 -10
- package/AGENTS.md +47 -2
- package/CHANGELOG.md +22 -0
- package/dist/browser-DzQ1yOHv.d.cts +69 -0
- package/dist/browser-DzQ1yOHv.d.ts +69 -0
- package/dist/browser.cjs +270 -0
- package/dist/browser.d.cts +1 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +6 -0
- package/dist/chunk-2Z2S2WY3.js +245 -0
- package/dist/index.cjs +250 -3
- package/dist/index.d.cts +45 -41
- package/dist/index.d.ts +45 -41
- package/dist/index.js +19 -2
- package/dist/server.cjs +275 -0
- package/dist/server.d.cts +25 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +18 -0
- package/package.json +21 -3
- package/src/bible-html-transformer-server.ts +37 -0
- package/src/bible-html-transformer.server.test.ts +151 -0
- package/src/bible-html-transformer.test.ts +403 -0
- package/src/bible-html-transformer.ts +378 -0
- package/src/bible.ts +10 -0
- package/src/browser.ts +4 -0
- package/src/index.ts +5 -0
- package/src/schemas/book.ts +3 -5
- package/src/server.ts +2 -0
- package/src/types/index.ts +32 -8
- package/src/utils/constants.ts +5 -3
- package/src/__tests__/mocks/configuration.ts +0 -53
- package/src/__tests__/mocks/jwt.ts +0 -93
- package/src/__tests__/mocks/tokens.ts +0 -69
- package/src/highlight.ts +0 -16
- package/src/types/api-config.ts +0 -7
- package/src/types/auth.ts +0 -18
- package/src/types/highlight.ts +0 -9
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { transformBibleHtml, transformBibleHtmlForBrowser } from './bible-html-transformer';
|
|
6
|
+
|
|
7
|
+
function createAdapters() {
|
|
8
|
+
return {
|
|
9
|
+
parseHtml: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
|
10
|
+
serializeHtml: (doc: Document) => doc.body.innerHTML,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('transformBibleHtml - intro chapter footnotes', () => {
|
|
15
|
+
it('should create data-verse-footnote anchors with intro keys for orphaned footnotes', () => {
|
|
16
|
+
const html = `
|
|
17
|
+
<div>
|
|
18
|
+
<div class="ip">Some intro text<span class="yv-n f"><span class="ft">First note</span></span> and more text<span class="yv-n f"><span class="ft">Second note</span></span>.</div>
|
|
19
|
+
</div>
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
23
|
+
|
|
24
|
+
expect(result.html).toContain('data-verse-footnote="intro-0"');
|
|
25
|
+
expect(result.html).toContain('data-verse-footnote="intro-1"');
|
|
26
|
+
expect(result.html).not.toContain('yv-n f');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should preserve footnote content in data-verse-footnote-content attribute', () => {
|
|
30
|
+
const html = `
|
|
31
|
+
<div>
|
|
32
|
+
<div class="ip">Text<span class="yv-n f"><span class="ft">See Rashi</span></span> more.</div>
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
37
|
+
|
|
38
|
+
expect(result.html).toContain('data-verse-footnote-content=');
|
|
39
|
+
expect(result.html).toContain('See Rashi');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should not interfere with regular verse footnotes when mixed', () => {
|
|
43
|
+
const html = `
|
|
44
|
+
<div>
|
|
45
|
+
<div class="ip">Intro text<span class="yv-n f"><span class="ft">Intro note</span></span>.</div>
|
|
46
|
+
<div class="p">
|
|
47
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text<span class="yv-n f"><span class="ft">Verse note</span></span>.
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
53
|
+
|
|
54
|
+
expect(result.html).toContain('data-verse-footnote="intro-0"');
|
|
55
|
+
expect(result.html).toContain('data-verse-footnote="1"');
|
|
56
|
+
expect(result.html).toContain('Intro note');
|
|
57
|
+
expect(result.html).toContain('Verse note');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should insert space when orphaned footnote is between two words', () => {
|
|
61
|
+
const html = `
|
|
62
|
+
<div>
|
|
63
|
+
<div class="ip">overcome<span class="yv-n f"><span class="ft">Note</span></span>it.</div>
|
|
64
|
+
</div>
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
68
|
+
|
|
69
|
+
expect(result.html).toContain('overcome ');
|
|
70
|
+
expect(result.html).not.toMatch(/overcome<span data-verse-footnote/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not insert space when orphaned footnote is followed by punctuation', () => {
|
|
74
|
+
const html = `
|
|
75
|
+
<div>
|
|
76
|
+
<div class="ip">overcome<span class="yv-n f"><span class="ft">Note</span></span>.</div>
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
81
|
+
|
|
82
|
+
expect(result.html).not.toContain('overcome .');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('transformBibleHtml - verse wrapping', () => {
|
|
87
|
+
it('should wrap verse content in .yv-v[v] elements', () => {
|
|
88
|
+
const html = `
|
|
89
|
+
<div>
|
|
90
|
+
<div class="p">
|
|
91
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse one text.
|
|
92
|
+
</div>
|
|
93
|
+
<div class="p">
|
|
94
|
+
<span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Verse two text.
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
100
|
+
|
|
101
|
+
expect(result.html).toMatch(/<span class="yv-v" v="1">/);
|
|
102
|
+
expect(result.html).toMatch(/<span class="yv-v" v="2">/);
|
|
103
|
+
expect(result.html).not.toContain('<span class="yv-v" v="1"></span>');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not wrap heading elements inside verse content', () => {
|
|
107
|
+
const html = `
|
|
108
|
+
<div>
|
|
109
|
+
<div class="p">
|
|
110
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Text before heading
|
|
111
|
+
</div>
|
|
112
|
+
<div class="s1">A Heading</div>
|
|
113
|
+
<div class="p">
|
|
114
|
+
<span class="yv-v" v="2"></span><span class="yv-vlbl">2</span>Text after heading
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
120
|
+
|
|
121
|
+
const doc = new DOMParser().parseFromString(result.html, 'text/html');
|
|
122
|
+
const heading = doc.querySelector('.s1');
|
|
123
|
+
expect(heading).not.toBeNull();
|
|
124
|
+
expect(heading!.closest('.yv-v')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('transformBibleHtml - addNbspToVerseLabels', () => {
|
|
129
|
+
it('should add non-breaking space after verse labels', () => {
|
|
130
|
+
const html = `
|
|
131
|
+
<div>
|
|
132
|
+
<div class="p">
|
|
133
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text.
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
139
|
+
|
|
140
|
+
const doc = new DOMParser().parseFromString(result.html, 'text/html');
|
|
141
|
+
const label = doc.querySelector('.yv-vlbl');
|
|
142
|
+
expect(label).not.toBeNull();
|
|
143
|
+
expect(label!.textContent).toContain('\u00A0');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should not duplicate non-breaking space if already present', () => {
|
|
147
|
+
const html = `
|
|
148
|
+
<div>
|
|
149
|
+
<div class="p">
|
|
150
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1\u00A0</span>Verse text.
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
156
|
+
|
|
157
|
+
const doc = new DOMParser().parseFromString(result.html, 'text/html');
|
|
158
|
+
const label = doc.querySelector('.yv-vlbl');
|
|
159
|
+
const text = label!.textContent ?? '';
|
|
160
|
+
const count = (text.match(/\u00A0/g) || []).length;
|
|
161
|
+
expect(count).toBeLessThanOrEqual(1);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('transformBibleHtml - fixIrregularTables', () => {
|
|
166
|
+
it('should set colspan on single-cell rows in multi-column tables', () => {
|
|
167
|
+
const html = `
|
|
168
|
+
<div>
|
|
169
|
+
<table>
|
|
170
|
+
<tr><td>Header Col 1</td><td>Header Col 2</td></tr>
|
|
171
|
+
<tr><td>Single cell spanning full width</td></tr>
|
|
172
|
+
</table>
|
|
173
|
+
</div>
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
177
|
+
|
|
178
|
+
const doc = new DOMParser().parseFromString(result.html, 'text/html');
|
|
179
|
+
const singleCell = doc.querySelector('tr:nth-child(2) td');
|
|
180
|
+
expect(singleCell).not.toBeNull();
|
|
181
|
+
expect(singleCell!.getAttribute('colspan')).toBe('2');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('transformBibleHtml - data attributes', () => {
|
|
186
|
+
it('should include data-verse-footnote attribute with verse key', () => {
|
|
187
|
+
const html = `
|
|
188
|
+
<div>
|
|
189
|
+
<div class="p">
|
|
190
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text<span class="yv-n f"><span class="ft">Note</span></span>.
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
196
|
+
|
|
197
|
+
expect(result.html).toContain('data-verse-footnote="1"');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should include data-verse-footnote-content attribute with footnote HTML', () => {
|
|
201
|
+
const html = `
|
|
202
|
+
<div>
|
|
203
|
+
<div class="p">
|
|
204
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text<span class="yv-n f"><span class="ft">See Rashi</span></span>.
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
210
|
+
|
|
211
|
+
expect(result.html).toContain('data-verse-footnote-content=');
|
|
212
|
+
expect(result.html).toContain('See Rashi');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should still run parseHtml when sanitize is not provided', () => {
|
|
216
|
+
const html = '<div>Test</div>';
|
|
217
|
+
let called = false;
|
|
218
|
+
|
|
219
|
+
transformBibleHtml(html, {
|
|
220
|
+
parseHtml: (h) => {
|
|
221
|
+
called = true;
|
|
222
|
+
return new DOMParser().parseFromString(h, 'text/html');
|
|
223
|
+
},
|
|
224
|
+
serializeHtml: (doc) => doc.body.innerHTML,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(called).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('transformBibleHtmlForBrowser', () => {
|
|
232
|
+
it('should transform HTML using native DOMParser', () => {
|
|
233
|
+
const html = `
|
|
234
|
+
<div>
|
|
235
|
+
<div class="p">
|
|
236
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text<span class="yv-n f"><span class="ft">Note</span></span>.
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
const result = transformBibleHtmlForBrowser(html);
|
|
242
|
+
|
|
243
|
+
expect(result.html).toBeDefined();
|
|
244
|
+
expect(result.html).toContain('data-verse-footnote="1"');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should return same result as transformBibleHtml with browser adapters', () => {
|
|
248
|
+
const html = `
|
|
249
|
+
<div>
|
|
250
|
+
<div class="p">
|
|
251
|
+
<span class="yv-v" v="1"></span><span class="yv-vlbl">1</span>Verse text.
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
const result1 = transformBibleHtmlForBrowser(html);
|
|
257
|
+
const result2 = transformBibleHtml(html, createAdapters());
|
|
258
|
+
|
|
259
|
+
expect(result1.html).toBe(result2.html);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should handle empty HTML', () => {
|
|
263
|
+
const result = transformBibleHtmlForBrowser('');
|
|
264
|
+
|
|
265
|
+
expect(result.html).toBeDefined();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('transformBibleHtml - return type', () => {
|
|
270
|
+
it('should return html property', () => {
|
|
271
|
+
const html = '<div>Test</div>';
|
|
272
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
273
|
+
|
|
274
|
+
expect(result).toHaveProperty('html');
|
|
275
|
+
expect(typeof result.html).toBe('string');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should not have notes or rawHtml properties', () => {
|
|
279
|
+
const html = '<div>Test</div>';
|
|
280
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
281
|
+
|
|
282
|
+
expect(result).not.toHaveProperty('notes');
|
|
283
|
+
expect(result).not.toHaveProperty('rawHtml');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('transformBibleHtml - sanitization', () => {
|
|
288
|
+
it('should remove script tags entirely', () => {
|
|
289
|
+
const html = '<p>Safe text</p><script>alert("XSS")</script>';
|
|
290
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
291
|
+
|
|
292
|
+
expect(result.html).not.toContain('script');
|
|
293
|
+
expect(result.html).not.toContain('alert');
|
|
294
|
+
expect(result.html).toContain('Safe text');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should remove img tags (not in allowlist)', () => {
|
|
298
|
+
const html = '<p>Text</p><img src="x" onerror="alert(\'XSS\')" />';
|
|
299
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
300
|
+
|
|
301
|
+
expect(result.html).not.toContain('img');
|
|
302
|
+
expect(result.html).not.toContain('onerror');
|
|
303
|
+
expect(result.html).toContain('Text');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should strip onclick attribute but preserve element and text', () => {
|
|
307
|
+
const html = '<p onclick="alert(\'XSS\')">Click me</p>';
|
|
308
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
309
|
+
|
|
310
|
+
expect(result.html).not.toContain('onclick');
|
|
311
|
+
expect(result.html).toContain('<p>');
|
|
312
|
+
expect(result.html).toContain('Click me');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should unwrap anchor tags (not in allowlist) preserving text', () => {
|
|
316
|
+
const html = '<p><a href="javascript:alert(\'XSS\')">Link</a></p>';
|
|
317
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
318
|
+
|
|
319
|
+
expect(result.html).not.toContain('<a');
|
|
320
|
+
expect(result.html).not.toContain('href');
|
|
321
|
+
expect(result.html).toContain('Link');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should remove svg tags entirely', () => {
|
|
325
|
+
const html = '<p>Text</p><svg onload="alert(1)"><circle></circle></svg>';
|
|
326
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
327
|
+
|
|
328
|
+
expect(result.html).not.toContain('svg');
|
|
329
|
+
expect(result.html).not.toContain('onload');
|
|
330
|
+
expect(result.html).not.toContain('circle');
|
|
331
|
+
expect(result.html).toContain('Text');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should strip style attribute from allowed tags', () => {
|
|
335
|
+
const html = '<div style="background:url(javascript:alert(1))">text</div>';
|
|
336
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
337
|
+
|
|
338
|
+
expect(result.html).not.toContain('style');
|
|
339
|
+
expect(result.html).toContain('<div>');
|
|
340
|
+
expect(result.html).toContain('text');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should preserve safe Bible HTML with allowed tags, classes, and attributes', () => {
|
|
344
|
+
const html = `
|
|
345
|
+
<div class="p">
|
|
346
|
+
<span class="wj">Jesus said</span>
|
|
347
|
+
</div>
|
|
348
|
+
<table><tr><td colspan="2">Cell</td></tr></table>
|
|
349
|
+
`;
|
|
350
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
351
|
+
|
|
352
|
+
expect(result.html).toContain('class="p"');
|
|
353
|
+
expect(result.html).toContain('class="wj"');
|
|
354
|
+
expect(result.html).toContain('colspan="2"');
|
|
355
|
+
expect(result.html).toContain('<table>');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should unwrap unknown custom elements preserving text', () => {
|
|
359
|
+
const html = '<p><custom-element>text</custom-element></p>';
|
|
360
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
361
|
+
|
|
362
|
+
expect(result.html).not.toContain('custom-element');
|
|
363
|
+
expect(result.html).toContain('text');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should remove iframe tags entirely', () => {
|
|
367
|
+
const html = '<p>Text</p><iframe src="https://evil.com"></iframe>';
|
|
368
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
369
|
+
|
|
370
|
+
expect(result.html).not.toContain('iframe');
|
|
371
|
+
expect(result.html).toContain('Text');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should preserve data-* attributes', () => {
|
|
375
|
+
const html = '<div data-slot="verse-container" data-custom="value">Content</div>';
|
|
376
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
377
|
+
|
|
378
|
+
expect(result.html).toContain('data-slot="verse-container"');
|
|
379
|
+
expect(result.html).toContain('data-custom="value"');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should preserve dir attribute for RTL support', () => {
|
|
383
|
+
const html = '<div dir="rtl"><p class="p">Hebrew text</p></div>';
|
|
384
|
+
const result = transformBibleHtml(html, createAdapters());
|
|
385
|
+
|
|
386
|
+
expect(result.html).toContain('dir="rtl"');
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('transformBibleHtmlForBrowser - DOMParser fallback', () => {
|
|
391
|
+
it('should throw when DOMParser is unavailable', () => {
|
|
392
|
+
const original = globalThis.DOMParser;
|
|
393
|
+
try {
|
|
394
|
+
// @ts-expect-error - intentionally removing DOMParser
|
|
395
|
+
globalThis.DOMParser = undefined;
|
|
396
|
+
expect(() => transformBibleHtmlForBrowser('<p>test</p>')).toThrow(
|
|
397
|
+
'DOMParser is required to transform Bible HTML in browser environments',
|
|
398
|
+
);
|
|
399
|
+
} finally {
|
|
400
|
+
globalThis.DOMParser = original;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|