@youversion/platform-core 1.20.2 → 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 +16 -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 +246 -2
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +16 -1
- 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 +23 -5
- 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/server.ts +2 -0
|
@@ -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
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';
|
package/src/server.ts
ADDED