From 2b1c38296fff67cc78e7517601d310928c37e2e6 Mon Sep 17 00:00:00 2001 From: camrun01 Date: Mon, 2 Nov 2026 16:38:10 -0600 Subject: [PATCH 1/6] feat(core): add paginated image search and VOTD image ids Add searchImages for GET /v1/search-images. Callers keep the same query, version id, and pageSize, and pass nextPageToken back as pageToken. An empty page can still include a token. HTTP 204 is an error. Verse-of-the-day reads use GET /v1/verse-of-the-days. language_ranges sends repeated language_ranges[] values. image_id is null when the API omits it or sends null. Re-export searchImages from the UI package so the public export check stays aligned. Signed-off-by: camrun01 --- .changeset/reexport-search-images.md | 5 + .changeset/search-images-votd.md | 7 + packages/core/README.md | 36 ++- packages/core/package.json | 2 + packages/core/src/__tests__/bible.test.ts | 2 +- packages/core/src/__tests__/handlers.ts | 4 +- .../core/src/__tests__/search-images.test.ts | 227 +++++++++++++++++ packages/core/src/__tests__/votd.test.ts | 230 ++++++++++++++++++ packages/core/src/bible-reads.ts | 48 +++- packages/core/src/bible.ts | 19 +- packages/core/src/index.ts | 9 +- packages/core/src/schemas/search.ts | 46 +++- packages/core/src/schemas/votd.ts | 70 +++++- packages/core/src/search-images.ts | 49 ++++ packages/core/src/search.ts | 21 +- packages/core/src/types/index.ts | 2 + packages/core/tsup.config.ts | 1 + packages/ui/src/index.ts | 1 + packages/ui/src/test/mocks/handlers.ts | 2 +- 19 files changed, 754 insertions(+), 27 deletions(-) create mode 100644 .changeset/reexport-search-images.md create mode 100644 .changeset/search-images-votd.md create mode 100644 packages/core/src/__tests__/search-images.test.ts create mode 100644 packages/core/src/__tests__/votd.test.ts create mode 100644 packages/core/src/search-images.ts diff --git a/.changeset/reexport-search-images.md b/.changeset/reexport-search-images.md new file mode 100644 index 000000000..594ae4367 --- /dev/null +++ b/.changeset/reexport-search-images.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': minor +--- + +Re-export `searchImages` from the UI package entry point. diff --git a/.changeset/search-images-votd.md b/.changeset/search-images-votd.md new file mode 100644 index 000000000..b7e2245c3 --- /dev/null +++ b/.changeset/search-images-votd.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': minor +--- + +Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. HTTP 204 is an error. + +Verse-of-the-day reads use `GET /v1/verse-of-the-days`. Pass `language_ranges` to send `language_ranges[]`. `image_id` is null when the API omits it or sends null. diff --git a/packages/core/README.md b/packages/core/README.md index 52adeb29b..8c0345c35 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -71,10 +71,12 @@ the CDN. Set `size` to `thumb`, `card`, or `full` on `image_url` when you want a smaller rendition. ```ts -import { ApiClient, ImagesClient } from '@youversion/platform-core'; +import { ApiClient, BibleClient, ImagesClient, SearchClient } from '@youversion/platform-core'; const apiClient = new ApiClient({ appKey: 'YOUR_APP_KEY' }); const images = new ImagesClient(apiClient); +const search = new SearchClient(apiClient); +const bibleClient = new BibleClient(apiClient); const page = await images.getImages({ passage_id: 'JHN.3.16', @@ -92,6 +94,38 @@ if (!first) { } ``` +Search verse-art images with `searchImages`. Keep the query, version id, and `pageSize` the same on every page. Pass `nextPageToken` back as `pageToken`. The last page uses `null`. An earlier page can be empty and still return a token, including an empty string. Pass each hit `id` to `getImage` unchanged. + +```ts +let pageToken: string | null = null; + +do { + const options: { pageSize: number; pageToken?: string } = { pageSize: 10 }; + if (pageToken !== null) { + options.pageToken = pageToken; + } + const found = await search.searchImages('love', 111, options); + for (const hit of found.images) { + const image = await images.getImage(hit.id); + console.log(hit.passageId, image.image_url); + } + pageToken = found.nextPageToken; +} while (pageToken !== null); +``` + +`getVOTD` and `getAllVOTDs` call `GET /v1/verse-of-the-days`. Omit `language_ranges` when you want no language query. Pass ranges to send repeated `language_ranges[]` values. The response keeps `day` and `passage_id`. `image_id` is `null` when the API omits it or sends null. Any other string stays unchanged. Pass that string to `getImage`. + +```ts +const today = await bibleClient.getVOTD(1, { language_ranges: ['en', 'es'] }); +if (today.image_id !== null) { + const curated = await images.getImage(today.image_id); + console.log(today.day, today.passage_id, curated.image_url); +} + +const calendar = await bibleClient.getAllVOTDs({ language_ranges: ['en'] }); +console.log(calendar.data[0]?.passage_id, calendar.data[0]?.image_id); +``` + `next_page_token` is `null` on the last page. Pass that value back as `page_token`, including `null`, to request the next page or the first page. `total_size` is present only when the API sends a number, which it does when diff --git a/packages/core/package.json b/packages/core/package.json index 7a1edfb08..54a41d1a1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,6 +43,8 @@ "!dist/search-queries.cjs", "!dist/search-verses.js", "!dist/search-verses.cjs", + "!dist/search-images.js", + "!dist/search-images.cjs", "!dist/search-topics.js", "!dist/search-topics.cjs", "!dist/search-unified.js", diff --git a/packages/core/src/__tests__/bible.test.ts b/packages/core/src/__tests__/bible.test.ts index 83bf8fdaf..ed9edeb43 100644 --- a/packages/core/src/__tests__/bible.test.ts +++ b/packages/core/src/__tests__/bible.test.ts @@ -54,7 +54,7 @@ describe('BibleClient runtime contracts', () => { expect(await client.getIndex(206)).toEqual(index); expect(requests.at(-1)?.pathname).toBe('/v1/bibles/206/index'); expect((await client.getAllVOTDs()).data[0]?.day).toBe(1); - expect(get).toHaveBeenCalledWith('/v1/verse_of_the_days'); + expect(get).toHaveBeenCalledWith('/v1/verse-of-the-days'); } finally { server.events.removeListener('request:start', onRequest); get.mockRestore(); diff --git a/packages/core/src/__tests__/handlers.ts b/packages/core/src/__tests__/handlers.ts index bba598be3..2b05f5a68 100644 --- a/packages/core/src/__tests__/handlers.ts +++ b/packages/core/src/__tests__/handlers.ts @@ -259,12 +259,12 @@ export const handlers = [ }), // Verse Of The Day endpoints - http.get(`https://${apiHost}/v1/verse_of_the_days/:day`, ({ params }) => { + http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, ({ params }) => { const { day } = params; const votd = mockAllVOTDs.find((v) => v.day === Number(day)); return HttpResponse.json(votd || mockAllVOTDs[0]); }), - http.get(`https://${apiHost}/v1/verse_of_the_days`, () => { + http.get(`https://${apiHost}/v1/verse-of-the-days`, () => { return HttpResponse.json({ data: mockAllVOTDs }); }), ]; diff --git a/packages/core/src/__tests__/search-images.test.ts b/packages/core/src/__tests__/search-images.test.ts new file mode 100644 index 000000000..0fc204ddc --- /dev/null +++ b/packages/core/src/__tests__/search-images.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ApiClient, getHttpStatus, ImagesClient, SearchClient, searchImages } from '../index'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST; + +const image = { + id: 'abc/def?x', + image_url: 'https://cdn.example/img.png', + passage_id: 'JHN.3.16', + attribution: 'YouVersion', + language_tag: 'en', + width: 1200, + height: 800, + aspect_ratio: 1.5, +}; + +function urlFromFetchInput(input: RequestInfo | URL | undefined): string { + if (input instanceof Request) return input.url; + if (input instanceof URL) return input.href; + return input ?? ''; +} + +function createClients() { + const api = new ApiClient({ + apiHost, + appKey: 'test-app', + installationId: 'test-installation', + }); + return { api, search: new SearchClient(api), images: new ImagesClient(api) }; +} + +describe('searchImages', () => { + it('pages image hits and resolves an opaque id with getImage', async () => { + const { api, search, images } = createClients(); + const fetchSpy = vi.spyOn(global, 'fetch'); + + try { + await expect(search.searchImages('', 111)).rejects.toThrow( + 'Query must be between 1 and 100 characters', + ); + await expect(search.searchImages(' ', 111)).rejects.toThrow( + 'Query must be between 1 and 100 characters', + ); + await expect(search.searchImages('a'.repeat(101), 111)).rejects.toThrow( + 'Query must be between 1 and 100 characters', + ); + await expect(search.searchImages('faith', 0)).rejects.toThrow( + 'Version ID must be a positive integer', + ); + await expect(search.searchImages('faith', 1.5)).rejects.toThrow(); + await expect(search.searchImages('faith', 111, { pageSize: 0 })).rejects.toThrow(); + await expect(search.searchImages('faith', 111, { pageSize: 100 })).rejects.toThrow(); + await expect(search.searchImages('faith', 111, { pageSize: 1.5 })).rejects.toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); + + server.use( + http.get(`https://${apiHost}/v1/search-images`, ({ request }) => { + const url = new URL(request.url); + const token = url.searchParams.get('page_token'); + const pageSize = url.searchParams.get('page_size'); + if (token === null && pageSize === null) { + return HttpResponse.json({ images: [], next_page_token: null }); + } + if (token === null) { + return HttpResponse.json({ + images: [ + { id: 'abc/def?x', passage_id: 'JHN.3.16', category: 'verse-art' }, + { id: '', passage_id: 'JHN.3.16' }, + { id: 'img-bad', passage_id: 'NOT USFM' }, + { id: null, passage_id: 'JHN.3.16' }, + { id: 'img-missing' }, + { id: 'img-2', passage_id: 'PSA.23' }, + ], + next_page_token: 'page/2?x', + }); + } + if (token === 'page/2?x') { + return HttpResponse.json({ images: [], next_page_token: '' }); + } + if (token === '') { + return HttpResponse.json({ images: [] }); + } + return HttpResponse.json({ images: [], next_page_token: null }); + }), + http.get(`https://${apiHost}/v1/images/abc%2Fdef%3Fx`, () => HttpResponse.json(image)), + ); + + const defaults = await searchImages(api, 'bread of life', 111); + expect(defaults).toEqual({ images: [], nextPageToken: null }); + + const populated = await search.searchImages('bread of life', 111, { pageSize: 10 }); + expect(populated).toEqual({ + images: [ + { id: 'abc/def?x', passageId: 'JHN.3.16' }, + { id: 'img-2', passageId: 'PSA.23' }, + ], + nextPageToken: 'page/2?x', + }); + + const firstToken = populated.nextPageToken; + if (firstToken === null) { + throw new Error('expected a continuation token'); + } + const continued = await search.searchImages('bread of life', 111, { + pageSize: 10, + pageToken: firstToken, + }); + expect(continued).toEqual({ images: [], nextPageToken: '' }); + + const emptyToken = continued.nextPageToken; + if (emptyToken === null) { + throw new Error('expected an empty continuation token'); + } + const finalPage = await search.searchImages('bread of life', 111, { + pageSize: 10, + pageToken: emptyToken, + }); + expect(finalPage).toEqual({ images: [], nextPageToken: null }); + + const detail = await images.getImage(populated.images[0]?.id ?? ''); + expect(detail).toEqual(image); + + const searchCalls = fetchSpy.mock.calls + .map((call) => new URL(urlFromFetchInput(call[0]))) + .filter((url) => url.pathname === '/v1/search-images'); + expect(searchCalls).toHaveLength(4); + + const defaultUrl = searchCalls[0]; + expect(defaultUrl?.searchParams.get('query')).toBe('bread of life'); + expect(defaultUrl?.searchParams.get('bible_id')).toBe('111'); + expect(defaultUrl?.searchParams.has('page_size')).toBe(false); + expect(defaultUrl?.searchParams.has('page_token')).toBe(false); + expect(defaultUrl?.searchParams.has('language_ranges[]')).toBe(false); + expect(defaultUrl?.searchParams.has('user_intent')).toBe(false); + expect(defaultUrl?.searchParams.has('fields[]')).toBe(false); + + for (const url of searchCalls.slice(1)) { + expect(url.searchParams.get('query')).toBe('bread of life'); + expect(url.searchParams.get('bible_id')).toBe('111'); + expect(url.searchParams.get('page_size')).toBe('10'); + expect(url.searchParams.has('language_ranges[]')).toBe(false); + } + expect(searchCalls[1]?.searchParams.has('page_token')).toBe(false); + expect(searchCalls[2]?.searchParams.get('page_token')).toBe('page/2?x'); + expect(searchCalls[3]?.searchParams.get('page_token')).toBe(''); + + const firstHeaders = new Headers(fetchSpy.mock.calls[0]?.[1]?.headers); + expect(firstHeaders.get('X-YVP-App-Key')).toBe('test-app'); + expect(firstHeaders.get('Authorization')).toBeNull(); + + const detailUrl = urlFromFetchInput(fetchSpy.mock.calls.at(-1)?.[0]); + expect(detailUrl).toContain('/v1/images/abc%2Fdef%3Fx'); + expect(detailUrl).not.toContain('abc%252F'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('rejects a broken search-images body, HTTP 204, and HTTP errors', async () => { + const { api } = createClients(); + + server.use( + http.get(`https://${apiHost}/v1/search-images`, ({ request }) => { + const url = new URL(request.url); + const token = url.searchParams.get('page_token'); + if (token === 'missing-images') { + return HttpResponse.json({ next_page_token: null }); + } + if (token === 'images-null') { + return HttpResponse.json({ images: null, next_page_token: null }); + } + if (token === 'token-number') { + return HttpResponse.json({ images: [], next_page_token: 1 }); + } + if (token === 'empty-body') { + return new HttpResponse('', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (token === 'no-content') { + return new HttpResponse(null, { status: 204 }); + } + if (token === 'http-400') { + return HttpResponse.json({ message: 'bad query' }, { status: 400 }); + } + if (token === 'explicit-null') { + return HttpResponse.json({ images: [], next_page_token: null }); + } + return HttpResponse.json({ images: [] }); + }), + ); + + await expect(searchImages(api, 'faith', 111, { pageToken: 'explicit-null' })).resolves.toEqual({ + images: [], + nextPageToken: null, + }); + await expect(searchImages(api, 'faith', 111, { pageToken: 'missing-images' })).rejects.toThrow( + /Unexpected search-images API response/, + ); + await expect(searchImages(api, 'faith', 111, { pageToken: 'images-null' })).rejects.toThrow( + /Unexpected search-images API response/, + ); + await expect(searchImages(api, 'faith', 111, { pageToken: 'token-number' })).rejects.toThrow( + /Unexpected search-images API response/, + ); + await expect(searchImages(api, 'faith', 111, { pageToken: 'empty-body' })).rejects.toThrow( + /Unexpected search-images API response/, + ); + await expect(searchImages(api, 'faith', 111, { pageToken: 'no-content' })).rejects.toThrow( + /Unexpected search-images API response/, + ); + + try { + await searchImages(api, 'faith', 111, { pageToken: 'http-400' }); + throw new Error('expected searchImages to reject'); + } catch (error) { + expect(getHttpStatus(error)).toBe(400); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).not.toMatch(/Unexpected search-images API response/); + } + } + }); +}); diff --git a/packages/core/src/__tests__/votd.test.ts b/packages/core/src/__tests__/votd.test.ts new file mode 100644 index 000000000..55c22cae5 --- /dev/null +++ b/packages/core/src/__tests__/votd.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ApiClient, BibleClient, getAllVOTDs, getHttpStatus, getVOTD } from '../index'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST; + +function createClients() { + const api = new ApiClient({ + apiHost, + appKey: 'test-app', + installationId: 'test-installation', + }); + return { api, bible: new BibleClient(api) }; +} + +function urlFromFetchInput(input: RequestInfo | URL | undefined): string { + if (input instanceof Request) return input.url; + if (input instanceof URL) return input.href; + return input ?? ''; +} + +describe('verse of the day reads', () => { + it('reads one day and the calendar on hyphenated routes', async () => { + const { api, bible } = createClients(); + const fetchSpy = vi.spyOn(global, 'fetch'); + + try { + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, ({ params }) => { + const day = String(params.day); + if (day === '1') { + return HttpResponse.json({ day: 1, passage_id: 'ISA.43.19' }); + } + if (day === '2') { + return HttpResponse.json({ day: 2, passage_id: 'HEB.13.5', image_id: null }); + } + if (day === '366') { + return HttpResponse.json({ + day: 366, + passage_id: 'PSA.23.1', + image_id: 'img/366?x', + }); + } + return HttpResponse.json({ day: Number(day), passage_id: 'JHN.3.16', image_id: '' }); + }), + http.get(`https://${apiHost}/v1/verse-of-the-days`, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has('language_ranges[]')) { + return HttpResponse.json({ + data: [ + { day: 1, passage_id: 'ISA.43.19', image_id: 'img/1?x' }, + { day: 2, passage_id: '1JN.2.15-16', image_id: null }, + { day: 3, passage_id: 'JHN.3.16' }, + { day: 366, passage_id: 'PSA.23.1', image_id: '' }, + ], + next_page_token: 'calendar', + total_size: 0, + }); + } + return HttpResponse.json({ + data: [ + { day: 1, passage_id: 'ISA.43.19' }, + { day: 2, passage_id: 'JHN.3.16' }, + ], + }); + }), + ); + + const missingImage = await bible.getVOTD(1); + expect(missingImage).toEqual({ day: 1, passage_id: 'ISA.43.19', image_id: null }); + + const nullImage = await getVOTD(api, 2, { language_ranges: ['en'] }); + expect(nullImage).toEqual({ day: 2, passage_id: 'HEB.13.5', image_id: null }); + + const curated = await bible.getVOTD(366, { language_ranges: [' EN ', 'en_US'] }); + expect(curated).toEqual({ day: 366, passage_id: 'PSA.23.1', image_id: 'img/366?x' }); + + const legacy = await bible.getAllVOTDs(); + expect(legacy).toEqual({ + data: [ + { day: 1, passage_id: 'ISA.43.19', image_id: null }, + { day: 2, passage_id: 'JHN.3.16', image_id: null }, + ], + next_page_token: null, + }); + expect(Object.hasOwn(legacy, 'total_size')).toBe(false); + + const calendar = await getAllVOTDs(api, { language_ranges: ['es', '*'] }); + expect(calendar).toEqual({ + data: [ + { day: 1, passage_id: 'ISA.43.19', image_id: 'img/1?x' }, + { day: 2, passage_id: '1JN.2.15-16', image_id: null }, + { day: 3, passage_id: 'JHN.3.16', image_id: null }, + { day: 366, passage_id: 'PSA.23.1', image_id: '' }, + ], + next_page_token: 'calendar', + total_size: 0, + }); + + const urls = fetchSpy.mock.calls.map((call) => new URL(urlFromFetchInput(call[0]))); + expect(urls.map((url) => url.pathname)).toEqual([ + '/v1/verse-of-the-days/1', + '/v1/verse-of-the-days/2', + '/v1/verse-of-the-days/366', + '/v1/verse-of-the-days', + '/v1/verse-of-the-days', + ]); + expect(urls[0]?.searchParams.has('language_ranges[]')).toBe(false); + expect(urls[1]?.searchParams.getAll('language_ranges[]')).toEqual(['en']); + expect(urls[2]?.searchParams.getAll('language_ranges[]')).toEqual(['en', 'en-us']); + expect(urls[3]?.searchParams.has('language_ranges[]')).toBe(false); + expect(urls[4]?.searchParams.getAll('language_ranges[]')).toEqual(['es', '*']); + expect(urls[2]?.search).toContain('language_ranges%5B%5D=en'); + expect(urls[2]?.search).toContain('language_ranges%5B%5D=en-us'); + + const headers = new Headers(fetchSpy.mock.calls[0]?.[1]?.headers); + expect(headers.get('X-YVP-App-Key')).toBe('test-app'); + expect(headers.get('Authorization')).toBeNull(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('rejects invalid verse-of-the-day input and broken responses', async () => { + const { api, bible } = createClients(); + const fetchSpy = vi.spyOn(global, 'fetch'); + + try { + await expect(bible.getVOTD(0)).rejects.toThrow(); + await expect(bible.getVOTD(367)).rejects.toThrow(); + await expect(getVOTD(api, 1.5)).rejects.toThrow(); + await expect(bible.getAllVOTDs({ language_ranges: [] })).rejects.toThrow( + /At least one language range/, + ); + await expect(bible.getVOTD(1, { language_ranges: [' '] })).rejects.toThrow( + 'Language ranges must be a non-empty string', + ); + await expect(getAllVOTDs(api, { language_ranges: ['not a range!!!'] })).rejects.toThrow( + /Language range must/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, () => + HttpResponse.json({ day: 1 }), + ), + ); + await expect(getVOTD(api, 1)).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, () => + HttpResponse.json({ day: 1, passage_id: 'JHN.3.16', image_id: 4 }), + ), + ); + await expect(bible.getVOTD(1)).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get( + `https://${apiHost}/v1/verse-of-the-days/:day`, + () => + new HttpResponse('', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + await expect(getVOTD(api, 1)).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get( + `https://${apiHost}/v1/verse-of-the-days/:day`, + () => new HttpResponse(null, { status: 204 }), + ), + ); + await expect(bible.getVOTD(1)).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days`, () => + HttpResponse.json({ + data: [ + { day: 1, passage_id: 'ISA.43.19' }, + { day: 400, passage_id: 'JHN.3.16' }, + { day: 3, passage_id: 'PSA.23.1' }, + ], + }), + ), + ); + await expect(getAllVOTDs(api)).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get( + `https://${apiHost}/v1/verse-of-the-days`, + () => new HttpResponse(null, { status: 204 }), + ), + ); + await expect(bible.getAllVOTDs()).rejects.toThrow(/Unexpected verse-of-the-day API response/); + + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, () => + HttpResponse.json({ message: 'unavailable' }, { status: 500 }), + ), + ); + try { + await getVOTD(api, 1); + throw new Error('expected getVOTD to reject'); + } catch (error) { + expect(getHttpStatus(error)).toBe(500); + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).not.toMatch(/Unexpected verse-of-the-day API response/); + } + } + + server.use( + http.get(`https://${apiHost}/v1/verse-of-the-days`, () => + HttpResponse.json({ message: 'missing' }, { status: 404 }), + ), + ); + try { + await bible.getAllVOTDs(); + throw new Error('expected getAllVOTDs to reject'); + } catch (error) { + expect(getHttpStatus(error)).toBe(404); + } + } finally { + fetchSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/bible-reads.ts b/packages/core/src/bible-reads.ts index 96fe43f26..8091f72a3 100644 --- a/packages/core/src/bible-reads.ts +++ b/packages/core/src/bible-reads.ts @@ -1,4 +1,3 @@ -import * as z from 'zod/mini'; import type { ApiClient } from './client'; import { assertUsableVersion, @@ -7,8 +6,19 @@ import { parseBibleVersionId, } from './bible-chapter'; import { BibleVerseNumberSchema } from './schemas/verse'; +import { + VOTDCollectionWireSchema, + VOTDRecordWireSchema, + parseVOTDDay, + parseVOTDLanguageQuery, + type GetVOTDOptions, +} from './schemas/votd'; import type { BibleBook, BibleChapter, BibleVerse, CANON, Collection, VOTD } from './types'; +export type { GetVOTDOptions }; + +const VOTD_COLLECTION_PATH = '/v1/verse-of-the-days'; + export async function getBooks( client: ApiClient, versionId: number, @@ -75,11 +85,37 @@ export async function getVerse( ); } -export async function getAllVOTDs(client: ApiClient): Promise> { - return client.get>(`/v1/verse_of_the_days`); +export async function getAllVOTDs( + client: ApiClient, + options?: GetVOTDOptions, +): Promise> { + const params = parseVOTDLanguageQuery(options); + const body = + params === undefined + ? await client.get(VOTD_COLLECTION_PATH) + : await client.get(VOTD_COLLECTION_PATH, params); + const parsed = VOTDCollectionWireSchema.safeParse(body); + if (!parsed.success) { + throw new Error(`Unexpected verse-of-the-day API response: ${parsed.error.message}`); + } + return parsed.data; } -export async function getVOTD(client: ApiClient, day: number): Promise { - z.int().check(z.gte(1), z.lte(366)).parse(day); - return client.get(`/v1/verse_of_the_days/${day}`); +export async function getVOTD( + client: ApiClient, + day: number, + options?: GetVOTDOptions, +): Promise { + const parsedDay = parseVOTDDay(day); + const path = `${VOTD_COLLECTION_PATH}/${parsedDay}`; + const params = parseVOTDLanguageQuery(options); + const body = + params === undefined + ? await client.get(path) + : await client.get(path, params); + const parsed = VOTDRecordWireSchema.safeParse(body); + if (!parsed.success) { + throw new Error(`Unexpected verse-of-the-day API response: ${parsed.error.message}`); + } + return parsed.data; } diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index d68cd5421..ff4460710 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -11,6 +11,7 @@ import { getVerse, getVerses, getVOTD, + type GetVOTDOptions, } from './bible-reads'; import { getVersions, type GetVersionsOptions } from './bible-versions'; import type { @@ -221,16 +222,20 @@ export class BibleClient { /** * Fetches the verse of the day calendar for an entire year. - * @returns A collection of VOTD objects for all days of the year. + * @param options Optional `language_ranges`. When omitted, the request sends no language query. + * When set, the request repeats `language_ranges[]` in that order. + * @returns Calendar rows for the year. Each row keeps `day` and `passage_id`. + * `image_id` is a string, or `null` when the API omits it or sends null. */ - async getAllVOTDs(): Promise> { - return getAllVOTDs(this.client); + async getAllVOTDs(options?: GetVOTDOptions): Promise> { + return getAllVOTDs(this.client, options); } /** - * Fetches the passage_id for the Verse Of The Day. + * Fetches the verse of the day for one day of the year. * @param day The day of the year (1-366). - * @returns The day of the year and the passage_id for that day of the year + * @param options Optional `language_ranges`. When omitted, the request sends no language query. + * @returns The day, `passage_id`, and `image_id`. `image_id` is `null` when the API omits it or sends null. * @example * ```ts * // Get the passageId for the verse of the first day of the year. @@ -240,7 +245,7 @@ export class BibleClient { * const passageId = await bibleClient.getVOTD(100); * ``` */ - async getVOTD(day: number): Promise { - return getVOTD(this.client, day); + async getVOTD(day: number, options?: GetVOTDOptions): Promise { + return getVOTD(this.client, day, options); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1659b73c3..f72ab7a8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export { getVerse, getVerses, getVOTD, + type GetVOTDOptions, } from './bible-reads'; export { getVersions, type GetVersionsOptions } from './bible-versions'; export { getPassage } from './bible-passage'; @@ -26,8 +27,14 @@ export { export { LanguagesClient } from './languages'; export { getLanguage } from './languages-language'; export { getLanguages, type GetLanguagesOptions } from './languages-list'; -export { SearchClient, type SearchUnifiedOptions, type SearchVersesOptions } from './search'; +export { + SearchClient, + type SearchImagesOptions, + type SearchUnifiedOptions, + type SearchVersesOptions, +} from './search'; export { getSuggestedQueries, getTrendingQueries } from './search-queries'; +export { searchImages } from './search-images'; export { searchVerses } from './search-verses'; export { searchTopics } from './search-topics'; export { searchUnified } from './search-unified'; diff --git a/packages/core/src/schemas/search.ts b/packages/core/src/schemas/search.ts index 83eec9277..86d0c2944 100644 --- a/packages/core/src/schemas/search.ts +++ b/packages/core/src/schemas/search.ts @@ -349,6 +349,45 @@ function toSearchImageHit(wire: SearchImageHitWire): SearchImageHit | null { }; } +function searchImageHits(hits: readonly unknown[]): SearchImageHit[] { + return hits.flatMap((hit) => { + const parsed = SearchImageHitWireSchema.safeParse(hit); + if (!parsed.success) { + return []; + } + const image = toSearchImageHit(parsed.data); + return image === null ? [] : [image]; + }); +} + +export const SearchImagesOptionsSchema = z.object({ + pageSize: z.optional(z.int().check(z.gte(1), z.lte(99))), + pageToken: z.optional(z.string()), +}); + +export type SearchImagesOptions = z.infer; + +export const SearchImagesWireSchema = z.object({ + images: z.array(z.unknown()), + next_page_token: z.optional(z.nullable(z.string())), +}); + +export type SearchImagesWire = z.infer; + +export const SearchImagesResponseSchema = z.object({ + images: z.array(SearchImageHitSchema), + nextPageToken: z.nullable(z.string()), +}); + +export type SearchImagesResponse = Readonly>; + +export function toSearchImagesResponse(wire: SearchImagesWire): SearchImagesResponse { + return { + images: searchImageHits(wire.images), + nextPageToken: wire.next_page_token ?? null, + }; +} + export function toSearchUnifiedResponse(wire: SearchUnifiedWire): SearchUnifiedResponse { const response: SearchUnifiedResponse = { verses: (wire.verses ?? []).flatMap((hit) => { @@ -366,12 +405,7 @@ export function toSearchUnifiedResponse(wire: SearchUnifiedWire): SearchUnifiedR } return [toSearchTopic(parsed.data)]; }), - images: (wire.images ?? []) - .map((hit) => { - const parsed = SearchImageHitWireSchema.safeParse(hit); - return parsed.success ? toSearchImageHit(parsed.data) : null; - }) - .filter((hit): hit is SearchImageHit => hit !== null), + images: searchImageHits(wire.images ?? []), didYouMean: wire.did_you_mean ?? [], searchInsteadFor: wire.search_instead_for ?? null, }; diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index 00ee1d8f4..7b137aba3 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -1,10 +1,78 @@ import * as z from 'zod/mini'; +import type { Collection } from './collection'; +import { parseLanguageRanges } from './search'; + +const VOTDDaySchema = z.int().check(z.gte(1), z.lte(366)); export const VOTDSchema = z.object({ /** Day of year (1-366) */ - day: z.int().check(z.gte(1), z.lte(366)), + day: VOTDDaySchema, /** Passage identifier (e.g., "JHN.3.16") */ passage_id: z.string(), + /** + * Curated image id for this day. Callers may omit it. + * Parsed responses use `null` when the API omits it or sends null. + */ + image_id: z.optional(z.nullable(z.string())), }); export type VOTD = Readonly>; + +export const VOTDRecordWireSchema = z.pipe( + z.object({ + day: VOTDDaySchema, + passage_id: z.string(), + image_id: z.optional(z.nullable(z.string())), + }), + z.transform( + (wire): VOTD => ({ + day: wire.day, + passage_id: wire.passage_id, + image_id: wire.image_id ?? null, + }), + ), +); + +export const VOTDCollectionWireSchema = z.pipe( + z.object({ + data: z.array(VOTDRecordWireSchema), + next_page_token: z.optional(z.nullable(z.string())), + total_size: z.optional(z.nullable(z.int().check(z.nonnegative()))), + }), + z.transform((wire): Collection => { + const page: Collection = { + data: wire.data, + next_page_token: wire.next_page_token ?? null, + }; + if (wire.total_size == null) { + return page; + } + return { ...page, total_size: wire.total_size }; + }), +); + +const GetVOTDOptionsSchema = z.object({ + language_ranges: z.optional( + z.readonly( + z.array(z.string()).check(z.minLength(1, 'At least one language range is required')), + ), + ), +}); + +export type GetVOTDOptions = Readonly<{ + language_ranges?: readonly string[]; +}>; + +export function parseVOTDDay(day: number): number { + return VOTDDaySchema.parse(day); +} + +export function parseVOTDLanguageQuery( + options?: GetVOTDOptions, +): { 'language_ranges[]': string[] } | undefined { + const parsed = GetVOTDOptionsSchema.parse(options ?? {}); + if (parsed.language_ranges === undefined) { + return undefined; + } + return { 'language_ranges[]': parseLanguageRanges(parsed.language_ranges) }; +} diff --git a/packages/core/src/search-images.ts b/packages/core/src/search-images.ts new file mode 100644 index 000000000..41751fc5e --- /dev/null +++ b/packages/core/src/search-images.ts @@ -0,0 +1,49 @@ +import type { ApiClient } from './client'; +import { BibleVersionIdSchema } from './schemas/version'; +import { + SearchImagesOptionsSchema, + SearchImagesWireSchema, + SearchTextQuerySchema, + toSearchImagesResponse, + type SearchImagesOptions, + type SearchImagesResponse, +} from './schemas/search'; + +export type { SearchImagesOptions }; + +type SearchImagesQuery = { + query: string; + bible_id: number; + page_size?: number; + page_token?: string; +}; + +export async function searchImages( + client: ApiClient, + query: string, + versionId: number, + options: SearchImagesOptions = {}, +): Promise { + const parsedQuery = SearchTextQuerySchema.parse(query); + BibleVersionIdSchema.parse(versionId); + const parsedOptions = SearchImagesOptionsSchema.parse(options); + + const params: SearchImagesQuery = { + query: parsedQuery, + bible_id: versionId, + }; + if (parsedOptions.pageSize !== undefined) { + params.page_size = parsedOptions.pageSize; + } + if (parsedOptions.pageToken !== undefined) { + params.page_token = parsedOptions.pageToken; + } + + const response = await client.get('/v1/search-images', params); + const parsed = SearchImagesWireSchema.safeParse(response); + if (!parsed.success) { + throw new Error(`Unexpected search-images API response: ${parsed.error.message}`); + } + + return toSearchImagesResponse(parsed.data); +} diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index f0c12960f..4db0c3f1d 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -2,15 +2,17 @@ import type { ApiClient } from './client'; import { getSuggestedQueries, getTrendingQueries } from './search-queries'; import { searchTopics } from './search-topics'; import { searchUnified, type SearchUnifiedOptions } from './search-unified'; +import { searchImages, type SearchImagesOptions } from './search-images'; import { searchVerses, type SearchVersesOptions } from './search-verses'; import type { + SearchImagesResponse, SearchQueries, SearchTopicsResponse, SearchUnifiedResponse, SearchVersesResponse, } from './schemas/search'; -export type { SearchUnifiedOptions, SearchVersesOptions }; +export type { SearchImagesOptions, SearchUnifiedOptions, SearchVersesOptions }; /** * Client for YouVersion Platform Search API endpoints (`/v1/*`). @@ -60,6 +62,23 @@ export class SearchClient { return searchVerses(this.client, query, versionId, options); } + /** + * Searches verse-art images in one Bible version. + * Pass the same query, version id, and `pageSize` when you request the next page. + * `nextPageToken` is `null` on the last page. An empty string token is kept. + * Resolve a hit with `ImagesClient.getImage` and the returned `id` unchanged. + * @param query Search text (1 to 100 graphemes). + * @param versionId Bible version identifier (sent to the API as `bible_id`). + * @param options Optional `pageSize` (1 to 99) and `pageToken`. + */ + async searchImages( + query: string, + versionId: number, + options?: SearchImagesOptions, + ): Promise { + return searchImages(this.client, query, versionId, options); + } + /** * Returns topics related to a query. This endpoint is unpaginated. * @param query Search text (1–100 characters). diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 6f3422cd0..c7b353f00 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -28,6 +28,8 @@ export type { User } from '../schemas/user'; export type { Highlight, CreateHighlight } from '../schemas/highlight'; export type { KnownSearchUserIntent, + SearchImagesOptions, + SearchImagesResponse, SearchQueries, SearchQuery, SearchImageHit, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 270b7ca43..5f67795d5 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ 'src/search.ts', 'src/search-queries.ts', 'src/search-verses.ts', + 'src/search-images.ts', 'src/search-topics.ts', 'src/search-unified.ts', 'src/highlights.ts', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 57e301d1e..eae06e9bc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -77,6 +77,7 @@ export { parseSearchLanguageRange, parseUsfmReference, removeStorageItem, + searchImages, searchTopics, searchUnified, searchVerses, diff --git a/packages/ui/src/test/mocks/handlers.ts b/packages/ui/src/test/mocks/handlers.ts index 27a334cf5..593f072a5 100644 --- a/packages/ui/src/test/mocks/handlers.ts +++ b/packages/ui/src/test/mocks/handlers.ts @@ -218,7 +218,7 @@ export const globalHandlers = [ }), // Verse of the day by day number - http.get('*/v1/verse_of_the_days/*', () => { + http.get('*/v1/verse-of-the-days/*', () => { return HttpResponse.json({ day: 1, passage_id: 'ISA.43.19', From 4dc5efe4b26d656dd15b51fb23a47b95ff88485e Mon Sep 17 00:00:00 2001 From: camrun01 Date: Mon, 2 Nov 2026 16:40:37 -0600 Subject: [PATCH 2/6] fix(core): derive VOTD options and narrow image example --- packages/core/README.md | 2 +- packages/core/src/schemas/votd.ts | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 8c0345c35..d49bd5d0e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -117,7 +117,7 @@ do { ```ts const today = await bibleClient.getVOTD(1, { language_ranges: ['en', 'es'] }); -if (today.image_id !== null) { +if (today.image_id != null) { const curated = await images.getImage(today.image_id); console.log(today.day, today.passage_id, curated.image_url); } diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index 7b137aba3..ec688445d 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -19,11 +19,7 @@ export const VOTDSchema = z.object({ export type VOTD = Readonly>; export const VOTDRecordWireSchema = z.pipe( - z.object({ - day: VOTDDaySchema, - passage_id: z.string(), - image_id: z.optional(z.nullable(z.string())), - }), + VOTDSchema, z.transform( (wire): VOTD => ({ day: wire.day, @@ -59,9 +55,7 @@ const GetVOTDOptionsSchema = z.object({ ), }); -export type GetVOTDOptions = Readonly<{ - language_ranges?: readonly string[]; -}>; +export type GetVOTDOptions = Readonly>; export function parseVOTDDay(day: number): number { return VOTDDaySchema.parse(day); From 3c48f282df6de8aaa5fc531f256f2a01c76c70ff Mon Sep 17 00:00:00 2001 From: camrun01 Date: Tue, 3 Nov 2026 12:10:54 -0600 Subject: [PATCH 3/6] fix(core): isolate mocked tests and language range helpers --- .../core/src/__tests__/search-images.test.ts | 2 +- .../core/src/__tests__/search-verses.test.ts | 2 +- packages/core/src/__tests__/votd.test.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/schemas/image.ts | 2 +- packages/core/src/schemas/index.ts | 1 + packages/core/src/schemas/language-range.ts | 38 +++++++++++++++++++ packages/core/src/schemas/search.ts | 37 ------------------ packages/core/src/schemas/votd.ts | 2 +- packages/core/src/search-queries.ts | 2 +- packages/core/src/search-topics.ts | 2 +- packages/core/src/search-unified.ts | 2 +- scripts/check-tree-shaking.mjs | 17 ++++++++- 13 files changed, 63 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/schemas/language-range.ts diff --git a/packages/core/src/__tests__/search-images.test.ts b/packages/core/src/__tests__/search-images.test.ts index 0fc204ddc..8c497522f 100644 --- a/packages/core/src/__tests__/search-images.test.ts +++ b/packages/core/src/__tests__/search-images.test.ts @@ -31,7 +31,7 @@ function createClients() { return { api, search: new SearchClient(api), images: new ImagesClient(api) }; } -describe('searchImages', () => { +describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('searchImages', () => { it('pages image hits and resolves an opaque id with getImage', async () => { const { api, search, images } = createClients(); const fetchSpy = vi.spyOn(global, 'fetch'); diff --git a/packages/core/src/__tests__/search-verses.test.ts b/packages/core/src/__tests__/search-verses.test.ts index c78d805a5..4ad103341 100644 --- a/packages/core/src/__tests__/search-verses.test.ts +++ b/packages/core/src/__tests__/search-verses.test.ts @@ -1,3 +1,4 @@ +import { parseSearchLanguageRange } from '../schemas/language-range'; import { describe, it, expect, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { ApiClient } from '../client'; @@ -7,7 +8,6 @@ import { SearchTextQuerySchema, clampSearchText, isValidStructuralUsfmReference, - parseSearchLanguageRange, } from '../schemas/search'; import { server } from './setup'; diff --git a/packages/core/src/__tests__/votd.test.ts b/packages/core/src/__tests__/votd.test.ts index 55c22cae5..1ee70c542 100644 --- a/packages/core/src/__tests__/votd.test.ts +++ b/packages/core/src/__tests__/votd.test.ts @@ -20,7 +20,7 @@ function urlFromFetchInput(input: RequestInfo | URL | undefined): string { return input ?? ''; } -describe('verse of the day reads', () => { +describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('verse of the day reads', () => { it('reads one day and the calendar on hyphenated routes', async () => { const { api, bible } = createClients(); const fetchSpy = vi.spyOn(global, 'fetch'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f72ab7a8b..a8193c941 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,8 +42,8 @@ export { MAX_SEARCH_QUERY_GRAPHEMES, clampSearchText, isValidStructuralUsfmReference, - parseSearchLanguageRange, } from './schemas/search'; +export { parseSearchLanguageRange } from './schemas/language-range'; export { parseUsfmReference, type UsfmReference } from './usfm-reference'; export { OrganizationsClient } from './organizations'; export { ImagesClient } from './images'; diff --git a/packages/core/src/schemas/image.ts b/packages/core/src/schemas/image.ts index a95a6f912..6474cad92 100644 --- a/packages/core/src/schemas/image.ts +++ b/packages/core/src/schemas/image.ts @@ -1,5 +1,5 @@ import * as z from 'zod/mini'; -import { parseSearchLanguageRange } from './search'; +import { parseSearchLanguageRange } from './language-range'; /** A verse-art image record from the Images API. */ export const ImageSchema = z.object({ diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index fafcc55b2..2cea84b5b 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -8,6 +8,7 @@ export * from './data-exchange'; export * from './font'; export * from './highlight'; export * from './language'; +export * from './language-range'; export * from './license'; export * from './organization'; export * from './passage'; diff --git a/packages/core/src/schemas/language-range.ts b/packages/core/src/schemas/language-range.ts new file mode 100644 index 000000000..51a4cadf7 --- /dev/null +++ b/packages/core/src/schemas/language-range.ts @@ -0,0 +1,38 @@ +import * as z from 'zod/mini'; + +/** Normalizes caller language ranges for search endpoints (`en_US` → `en-us`). */ +export function normalizeSearchLanguageRange(range: string): string { + return range.trim().replace(/_/g, '-').toLowerCase(); +} + +/** + * Platform Search `language_ranges[]` filter grammar: `*` or a Basic Language Range + * subset (ISO 639 primary subtag plus optional hyphen extensions such as region). + * Not full BCP 47. Language API resource ids use {@link BCP47_LANGUAGE_TAG_REGEX}. + */ +const SEARCH_LANGUAGE_RANGE_REGEX = /^(\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)$/; + +const SEARCH_LANGUAGE_RANGE_ERROR = + 'Language range must be "*" or a Basic Language Range (e.g., "en", "en-US")'; + +/** Validates and normalizes one search language range (`EN`, `en_US` → `en`, `en-us`). */ +export function parseSearchLanguageRange(range: string): string { + const trimmed = z + .string() + .check(z.trim(), z.minLength(1, 'Language ranges must be a non-empty string')) + .parse(range); + const normalized = normalizeSearchLanguageRange(trimmed); + z.string() + .check(z.regex(SEARCH_LANGUAGE_RANGE_REGEX, SEARCH_LANGUAGE_RANGE_ERROR)) + .parse(normalized); + return normalized; +} + +/** Validates and normalizes one or more search language ranges. */ +export function parseLanguageRanges(languageRanges: string | readonly string[]): string[] { + const rangeArray = Array.isArray(languageRanges) ? [...languageRanges] : [languageRanges]; + z.array(z.string()) + .check(z.minLength(1, 'At least one language range is required')) + .parse(rangeArray); + return rangeArray.map(parseSearchLanguageRange); +} diff --git a/packages/core/src/schemas/search.ts b/packages/core/src/schemas/search.ts index 86d0c2944..4fd2409a1 100644 --- a/packages/core/src/schemas/search.ts +++ b/packages/core/src/schemas/search.ts @@ -42,43 +42,6 @@ export function isValidStructuralUsfmReference(usfm: string): boolean { return parseUsfmReference(usfm) !== null; } -/** Normalizes caller language ranges for search endpoints (`en_US` → `en-us`). */ -export function normalizeSearchLanguageRange(range: string): string { - return range.trim().replace(/_/g, '-').toLowerCase(); -} - -/** - * Platform Search `language_ranges[]` filter grammar: `*` or a Basic Language Range - * subset (ISO 639 primary subtag plus optional hyphen extensions such as region). - * Not full BCP 47. Language API resource ids use {@link BCP47_LANGUAGE_TAG_REGEX}. - */ -const SEARCH_LANGUAGE_RANGE_REGEX = /^(\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)$/; - -const SEARCH_LANGUAGE_RANGE_ERROR = - 'Language range must be "*" or a Basic Language Range (e.g., "en", "en-US")'; - -/** Validates and normalizes one search language range (`EN`, `en_US` → `en`, `en-us`). */ -export function parseSearchLanguageRange(range: string): string { - const trimmed = z - .string() - .check(z.trim(), z.minLength(1, 'Language ranges must be a non-empty string')) - .parse(range); - const normalized = normalizeSearchLanguageRange(trimmed); - z.string() - .check(z.regex(SEARCH_LANGUAGE_RANGE_REGEX, SEARCH_LANGUAGE_RANGE_ERROR)) - .parse(normalized); - return normalized; -} - -/** Validates and normalizes one or more search language ranges. */ -export function parseLanguageRanges(languageRanges: string | readonly string[]): string[] { - const rangeArray = Array.isArray(languageRanges) ? [...languageRanges] : [languageRanges]; - z.array(z.string()) - .check(z.minLength(1, 'At least one language range is required')) - .parse(rangeArray); - return rangeArray.map(parseSearchLanguageRange); -} - export const SearchQuerySchema = z.object({ /** Suggested or trending query text */ text: z.string(), diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index ec688445d..05f9688ab 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -1,6 +1,6 @@ import * as z from 'zod/mini'; import type { Collection } from './collection'; -import { parseLanguageRanges } from './search'; +import { parseLanguageRanges } from './language-range'; const VOTDDaySchema = z.int().check(z.gte(1), z.lte(366)); diff --git a/packages/core/src/search-queries.ts b/packages/core/src/search-queries.ts index 87ca11fae..b92deffa5 100644 --- a/packages/core/src/search-queries.ts +++ b/packages/core/src/search-queries.ts @@ -1,6 +1,6 @@ +import { parseLanguageRanges } from './schemas/language-range'; import type { ApiClient } from './client'; import { - parseLanguageRanges, SearchQueriesSchema, SearchQueriesWireSchema, SuggestedSearchQuerySchema, diff --git a/packages/core/src/search-topics.ts b/packages/core/src/search-topics.ts index 1f220c7e1..b6bb76952 100644 --- a/packages/core/src/search-topics.ts +++ b/packages/core/src/search-topics.ts @@ -1,6 +1,6 @@ +import { parseLanguageRanges } from './schemas/language-range'; import type { ApiClient } from './client'; import { - parseLanguageRanges, SearchTextQuerySchema, SearchTopicsWireSchema, toSearchTopicsResponse, diff --git a/packages/core/src/search-unified.ts b/packages/core/src/search-unified.ts index 17f9efcd7..593681479 100644 --- a/packages/core/src/search-unified.ts +++ b/packages/core/src/search-unified.ts @@ -1,7 +1,7 @@ +import { parseLanguageRanges } from './schemas/language-range'; import type { ApiClient } from './client'; import { BibleVersionIdSchema } from './schemas/version'; import { - parseLanguageRanges, SearchTextQuerySchema, SearchUnifiedOptionsSchema, SearchUnifiedWireSchema, diff --git a/scripts/check-tree-shaking.mjs b/scripts/check-tree-shaking.mjs index 38bf73a85..bb325f4cd 100644 --- a/scripts/check-tree-shaking.mjs +++ b/scripts/check-tree-shaking.mjs @@ -152,6 +152,19 @@ const CHECKS = [ }, ], focused: [ + { + label: 'Bible reads only', + source: `import { BibleClient, getBooks } from '@youversion/platform-core'; +export { BibleClient, getBooks }; +`, + absent: ['At least one search field is required'], + control: { + label: 'SearchClient', + source: `import { SearchClient } from '@youversion/platform-core'; +export { SearchClient }; +`, + }, + }, { label: 'getBibleStylesheets only', source: `import { getBibleStylesheets } from '@youversion/platform-core'; @@ -420,7 +433,7 @@ async function runPackageCheck(check) { for (const focused of check.focused ?? []) { const bundle = await bundleConsumer(focused.source, check.external); const leaked = focused.absent.filter((sentinel) => bundle.text.includes(sentinel)); - const sizePass = bundle.bytes <= focused.maxBytes; + const sizePass = focused.maxBytes === undefined || bundle.bytes <= focused.maxBytes; const pass = leaked.length === 0 && sizePass; const details = []; if (leaked.length > 0) { @@ -434,7 +447,7 @@ async function runPackageCheck(check) { label: focused.label, bytes: bundle.bytes, pass, - detail: pass ? 'isolated from display orchestration' : details.join('; '), + detail: pass ? 'no leaked sentinels; within any size limit' : details.join('; '), }); if (!pass) { errors.push(`${check.package} focused import "${focused.label}" is not isolated`); From 4dd7b9191efa915423f743a6c9382ec25832f408 Mon Sep 17 00:00:00 2001 From: camrun01 Date: Wed, 4 Nov 2026 08:30:46 -0600 Subject: [PATCH 4/6] fix(core): expose normalized VOTD output types --- .changeset/search-images-votd.md | 4 +++- packages/core/src/__tests__/bible.test.ts | 2 +- packages/core/src/__tests__/votd.test.ts | 18 +++++++++++++++--- packages/core/src/bible-reads.ts | 10 ++-------- packages/core/src/schemas/votd.ts | 16 ++++++++-------- packages/core/src/types/index.ts | 2 +- .../src/components/verse-of-the-day.test.tsx | 2 +- 7 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.changeset/search-images-votd.md b/.changeset/search-images-votd.md index b7e2245c3..fbb42cfcb 100644 --- a/.changeset/search-images-votd.md +++ b/.changeset/search-images-votd.md @@ -4,4 +4,6 @@ Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. HTTP 204 is an error. -Verse-of-the-day reads use `GET /v1/verse-of-the-days`. Pass `language_ranges` to send `language_ranges[]`. `image_id` is null when the API omits it or sends null. +Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Parsed `VOTD` results guarantee `image_id: string | null`, including `null` when the API omits the field. Use `VOTDInput` for caller-created values that omit `image_id`. + +VOTD reads now validate responses instead of returning unchecked bodies. `getAllVOTDs` requires a `{ data: [...] }` collection envelope. Both methods reject malformed responses with an `Unexpected verse-of-the-day API response` error, including invalid day, passage, or image field types. diff --git a/packages/core/src/__tests__/bible.test.ts b/packages/core/src/__tests__/bible.test.ts index ed9edeb43..d97e2eab7 100644 --- a/packages/core/src/__tests__/bible.test.ts +++ b/packages/core/src/__tests__/bible.test.ts @@ -54,7 +54,7 @@ describe('BibleClient runtime contracts', () => { expect(await client.getIndex(206)).toEqual(index); expect(requests.at(-1)?.pathname).toBe('/v1/bibles/206/index'); expect((await client.getAllVOTDs()).data[0]?.day).toBe(1); - expect(get).toHaveBeenCalledWith('/v1/verse-of-the-days'); + expect(requests.at(-1)?.pathname).toBe('/v1/verse-of-the-days'); } finally { server.events.removeListener('request:start', onRequest); get.mockRestore(); diff --git a/packages/core/src/__tests__/votd.test.ts b/packages/core/src/__tests__/votd.test.ts index 1ee70c542..6c6481905 100644 --- a/packages/core/src/__tests__/votd.test.ts +++ b/packages/core/src/__tests__/votd.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { ApiClient, BibleClient, getAllVOTDs, getHttpStatus, getVOTD } from '../index'; +import { + ApiClient, + BibleClient, + getAllVOTDs, + getHttpStatus, + getVOTD, + type VOTDInput, +} from '../index'; import { server } from './setup'; const apiHost = process.env.YVP_API_HOST; @@ -24,13 +31,14 @@ describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('verse of the day reads' it('reads one day and the calendar on hyphenated routes', async () => { const { api, bible } = createClients(); const fetchSpy = vi.spyOn(global, 'fetch'); + const legacyInput: VOTDInput = { day: 1, passage_id: 'ISA.43.19' }; try { server.use( http.get(`https://${apiHost}/v1/verse-of-the-days/:day`, ({ params }) => { const day = String(params.day); if (day === '1') { - return HttpResponse.json({ day: 1, passage_id: 'ISA.43.19' }); + return HttpResponse.json(legacyInput); } if (day === '2') { return HttpResponse.json({ day: 2, passage_id: 'HEB.13.5', image_id: null }); @@ -68,15 +76,18 @@ describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('verse of the day reads' ); const missingImage = await bible.getVOTD(1); + expectTypeOf(missingImage.image_id).toEqualTypeOf(); expect(missingImage).toEqual({ day: 1, passage_id: 'ISA.43.19', image_id: null }); const nullImage = await getVOTD(api, 2, { language_ranges: ['en'] }); + expectTypeOf(nullImage.image_id).toEqualTypeOf(); expect(nullImage).toEqual({ day: 2, passage_id: 'HEB.13.5', image_id: null }); const curated = await bible.getVOTD(366, { language_ranges: [' EN ', 'en_US'] }); expect(curated).toEqual({ day: 366, passage_id: 'PSA.23.1', image_id: 'img/366?x' }); const legacy = await bible.getAllVOTDs(); + expectTypeOf<(typeof legacy.data)[number]['image_id']>().toEqualTypeOf(); expect(legacy).toEqual({ data: [ { day: 1, passage_id: 'ISA.43.19', image_id: null }, @@ -87,6 +98,7 @@ describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('verse of the day reads' expect(Object.hasOwn(legacy, 'total_size')).toBe(false); const calendar = await getAllVOTDs(api, { language_ranges: ['es', '*'] }); + expectTypeOf<(typeof calendar.data)[number]['image_id']>().toEqualTypeOf(); expect(calendar).toEqual({ data: [ { day: 1, passage_id: 'ISA.43.19', image_id: 'img/1?x' }, diff --git a/packages/core/src/bible-reads.ts b/packages/core/src/bible-reads.ts index 8091f72a3..1c93a6a47 100644 --- a/packages/core/src/bible-reads.ts +++ b/packages/core/src/bible-reads.ts @@ -90,10 +90,7 @@ export async function getAllVOTDs( options?: GetVOTDOptions, ): Promise> { const params = parseVOTDLanguageQuery(options); - const body = - params === undefined - ? await client.get(VOTD_COLLECTION_PATH) - : await client.get(VOTD_COLLECTION_PATH, params); + const body = await client.get(VOTD_COLLECTION_PATH, params); const parsed = VOTDCollectionWireSchema.safeParse(body); if (!parsed.success) { throw new Error(`Unexpected verse-of-the-day API response: ${parsed.error.message}`); @@ -109,10 +106,7 @@ export async function getVOTD( const parsedDay = parseVOTDDay(day); const path = `${VOTD_COLLECTION_PATH}/${parsedDay}`; const params = parseVOTDLanguageQuery(options); - const body = - params === undefined - ? await client.get(path) - : await client.get(path, params); + const body = await client.get(path, params); const parsed = VOTDRecordWireSchema.safeParse(body); if (!parsed.success) { throw new Error(`Unexpected verse-of-the-day API response: ${parsed.error.message}`); diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index 05f9688ab..3544947ba 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -16,19 +16,19 @@ export const VOTDSchema = z.object({ image_id: z.optional(z.nullable(z.string())), }); -export type VOTD = Readonly>; +export type VOTDInput = Readonly>; export const VOTDRecordWireSchema = z.pipe( VOTDSchema, - z.transform( - (wire): VOTD => ({ - day: wire.day, - passage_id: wire.passage_id, - image_id: wire.image_id ?? null, - }), - ), + z.transform((wire) => ({ + day: wire.day, + passage_id: wire.passage_id, + image_id: wire.image_id ?? null, + })), ); +export type VOTD = Readonly>; + export const VOTDCollectionWireSchema = z.pipe( z.object({ data: z.array(VOTDRecordWireSchema), diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index c7b353f00..0768d34d0 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -15,7 +15,7 @@ export type { PassageAttribution, PassageStylesheet, } from '../schemas/passage-display'; -export type { VOTD } from '../schemas/votd'; +export type { VOTD, VOTDInput } from '../schemas/votd'; export type { BibleIndex, BibleIndexBook, diff --git a/packages/ui/src/components/verse-of-the-day.test.tsx b/packages/ui/src/components/verse-of-the-day.test.tsx index 8c6f6897e..b50fe9a7d 100644 --- a/packages/ui/src/components/verse-of-the-day.test.tsx +++ b/packages/ui/src/components/verse-of-the-day.test.tsx @@ -60,7 +60,7 @@ function stubOverrides( return { useVerseOfTheDay: () => ({ - data: votdLoading ? null : { day: 1, passage_id: 'JHN.3.16' }, + data: votdLoading ? null : { day: 1, passage_id: 'JHN.3.16', image_id: null }, loading: votdLoading, error: votdError, refetch: () => undefined, From cf7b6fb1b0ff93a41239dd30d4a00f82b1c5121a Mon Sep 17 00:00:00 2001 From: camrun01 Date: Wed, 4 Nov 2026 08:49:18 -0600 Subject: [PATCH 5/6] fix(core): preserve legacy VOTD value compatibility --- .changeset/search-images-votd.md | 2 +- packages/core/src/__tests__/votd.test.ts | 11 ++--------- packages/core/src/bible-reads.ts | 6 +++--- packages/core/src/bible.ts | 6 +++--- packages/core/src/schemas/votd.ts | 9 +++++---- packages/core/src/types/index.ts | 2 +- packages/ui/src/components/verse-of-the-day.test.tsx | 2 +- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/.changeset/search-images-votd.md b/.changeset/search-images-votd.md index fbb42cfcb..1eeb20345 100644 --- a/.changeset/search-images-votd.md +++ b/.changeset/search-images-votd.md @@ -4,6 +4,6 @@ Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. HTTP 204 is an error. -Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Parsed `VOTD` results guarantee `image_id: string | null`, including `null` when the API omits the field. Use `VOTDInput` for caller-created values that omit `image_id`. +Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Core client methods return `VOTDResponse` records with a guaranteed `image_id: string | null`, including `null` when the API omits the field. The existing `VOTD` type and hook overrides still accept values without `image_id`. `VOTDInput` is an alias for that compatible shape. VOTD reads now validate responses instead of returning unchecked bodies. `getAllVOTDs` requires a `{ data: [...] }` collection envelope. Both methods reject malformed responses with an `Unexpected verse-of-the-day API response` error, including invalid day, passage, or image field types. diff --git a/packages/core/src/__tests__/votd.test.ts b/packages/core/src/__tests__/votd.test.ts index 6c6481905..03d0edd75 100644 --- a/packages/core/src/__tests__/votd.test.ts +++ b/packages/core/src/__tests__/votd.test.ts @@ -1,13 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { - ApiClient, - BibleClient, - getAllVOTDs, - getHttpStatus, - getVOTD, - type VOTDInput, -} from '../index'; +import { ApiClient, BibleClient, getAllVOTDs, getHttpStatus, getVOTD, type VOTD } from '../index'; import { server } from './setup'; const apiHost = process.env.YVP_API_HOST; @@ -31,7 +24,7 @@ describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('verse of the day reads' it('reads one day and the calendar on hyphenated routes', async () => { const { api, bible } = createClients(); const fetchSpy = vi.spyOn(global, 'fetch'); - const legacyInput: VOTDInput = { day: 1, passage_id: 'ISA.43.19' }; + const legacyInput: VOTD = { day: 1, passage_id: 'ISA.43.19' }; try { server.use( diff --git a/packages/core/src/bible-reads.ts b/packages/core/src/bible-reads.ts index 1c93a6a47..033f0e9b8 100644 --- a/packages/core/src/bible-reads.ts +++ b/packages/core/src/bible-reads.ts @@ -13,7 +13,7 @@ import { parseVOTDLanguageQuery, type GetVOTDOptions, } from './schemas/votd'; -import type { BibleBook, BibleChapter, BibleVerse, CANON, Collection, VOTD } from './types'; +import type { BibleBook, BibleChapter, BibleVerse, CANON, Collection, VOTDResponse } from './types'; export type { GetVOTDOptions }; @@ -88,7 +88,7 @@ export async function getVerse( export async function getAllVOTDs( client: ApiClient, options?: GetVOTDOptions, -): Promise> { +): Promise> { const params = parseVOTDLanguageQuery(options); const body = await client.get(VOTD_COLLECTION_PATH, params); const parsed = VOTDCollectionWireSchema.safeParse(body); @@ -102,7 +102,7 @@ export async function getVOTD( client: ApiClient, day: number, options?: GetVOTDOptions, -): Promise { +): Promise { const parsedDay = parseVOTDDay(day); const path = `${VOTD_COLLECTION_PATH}/${parsedDay}`; const params = parseVOTDLanguageQuery(options); diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index ff4460710..a620a4196 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -23,7 +23,7 @@ import type { BibleVersion, CANON, Collection, - VOTD, + VOTDResponse, } from './types'; /** @@ -227,7 +227,7 @@ export class BibleClient { * @returns Calendar rows for the year. Each row keeps `day` and `passage_id`. * `image_id` is a string, or `null` when the API omits it or sends null. */ - async getAllVOTDs(options?: GetVOTDOptions): Promise> { + async getAllVOTDs(options?: GetVOTDOptions): Promise> { return getAllVOTDs(this.client, options); } @@ -245,7 +245,7 @@ export class BibleClient { * const passageId = await bibleClient.getVOTD(100); * ``` */ - async getVOTD(day: number, options?: GetVOTDOptions): Promise { + async getVOTD(day: number, options?: GetVOTDOptions): Promise { return getVOTD(this.client, day, options); } } diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index 3544947ba..cadbc12d5 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -16,7 +16,8 @@ export const VOTDSchema = z.object({ image_id: z.optional(z.nullable(z.string())), }); -export type VOTDInput = Readonly>; +export type VOTD = Readonly>; +export type VOTDInput = VOTD; export const VOTDRecordWireSchema = z.pipe( VOTDSchema, @@ -27,7 +28,7 @@ export const VOTDRecordWireSchema = z.pipe( })), ); -export type VOTD = Readonly>; +export type VOTDResponse = Readonly>; export const VOTDCollectionWireSchema = z.pipe( z.object({ @@ -35,8 +36,8 @@ export const VOTDCollectionWireSchema = z.pipe( next_page_token: z.optional(z.nullable(z.string())), total_size: z.optional(z.nullable(z.int().check(z.nonnegative()))), }), - z.transform((wire): Collection => { - const page: Collection = { + z.transform((wire): Collection => { + const page: Collection = { data: wire.data, next_page_token: wire.next_page_token ?? null, }; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 0768d34d0..f86e128c1 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -15,7 +15,7 @@ export type { PassageAttribution, PassageStylesheet, } from '../schemas/passage-display'; -export type { VOTD, VOTDInput } from '../schemas/votd'; +export type { VOTD, VOTDInput, VOTDResponse } from '../schemas/votd'; export type { BibleIndex, BibleIndexBook, diff --git a/packages/ui/src/components/verse-of-the-day.test.tsx b/packages/ui/src/components/verse-of-the-day.test.tsx index b50fe9a7d..8c6f6897e 100644 --- a/packages/ui/src/components/verse-of-the-day.test.tsx +++ b/packages/ui/src/components/verse-of-the-day.test.tsx @@ -60,7 +60,7 @@ function stubOverrides( return { useVerseOfTheDay: () => ({ - data: votdLoading ? null : { day: 1, passage_id: 'JHN.3.16', image_id: null }, + data: votdLoading ? null : { day: 1, passage_id: 'JHN.3.16' }, loading: votdLoading, error: votdError, refetch: () => undefined, From 538afb1c1e017f4b867d7dcc710fc9b031b5be37 Mon Sep 17 00:00:00 2001 From: camrun01 Date: Wed, 4 Nov 2026 11:11:10 -0600 Subject: [PATCH 6/6] fix(core): address remaining image API review comments --- .changeset/search-images-votd.md | 4 ++-- packages/core/README.md | 2 +- packages/core/src/__tests__/search-images.test.ts | 4 ++-- packages/core/src/bible.ts | 7 ++++++- packages/core/src/schemas/votd.ts | 1 - packages/core/src/search-images.ts | 3 +++ packages/core/src/types/index.ts | 2 +- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.changeset/search-images-votd.md b/.changeset/search-images-votd.md index 1eeb20345..5c4b00e43 100644 --- a/.changeset/search-images-votd.md +++ b/.changeset/search-images-votd.md @@ -2,8 +2,8 @@ '@youversion/platform-core': minor --- -Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. HTTP 204 is an error. +Add paginated `searchImages` for `GET /v1/search-images`. Pass the same query, version id, and `pageSize` on each page, then pass `nextPageToken` back as `pageToken`. An empty page can still include a token. Empty success bodies, including HTTP 204, reject with `search-images returned no content; expected a JSON response`. -Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Core client methods return `VOTDResponse` records with a guaranteed `image_id: string | null`, including `null` when the API omits the field. The existing `VOTD` type and hook overrides still accept values without `image_id`. `VOTDInput` is an alias for that compatible shape. +Verse-of-the-day reads move from `/v1/verse_of_the_days` to the canonical `/v1/verse-of-the-days` routes for both single days and the calendar. Pass `language_ranges` to send `language_ranges[]`. Core client methods return `VOTDResponse` records with a guaranteed `image_id: string | null`, including `null` when the API omits the field. The existing `VOTD` type and hook overrides still accept values without `image_id`. VOTD reads now validate responses instead of returning unchecked bodies. `getAllVOTDs` requires a `{ data: [...] }` collection envelope. Both methods reject malformed responses with an `Unexpected verse-of-the-day API response` error, including invalid day, passage, or image field types. diff --git a/packages/core/README.md b/packages/core/README.md index d49bd5d0e..4e0e53fa4 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -113,7 +113,7 @@ do { } while (pageToken !== null); ``` -`getVOTD` and `getAllVOTDs` call `GET /v1/verse-of-the-days`. Omit `language_ranges` when you want no language query. Pass ranges to send repeated `language_ranges[]` values. The response keeps `day` and `passage_id`. `image_id` is `null` when the API omits it or sends null. Any other string stays unchanged. Pass that string to `getImage`. +`getVOTD` and `getAllVOTDs` call `GET /v1/verse-of-the-days`. Omit `language_ranges` when you want no language query. Supplied ranges are trimmed, lowercased, and normalized from underscores to hyphens (`' EN '` becomes `en`; `en_US` becomes `en-us`). Invalid Basic Language Ranges are rejected before the request. Valid ranges are sent in the supplied order as repeated `language_ranges[]` parameters. The response keeps `day` and `passage_id`. `image_id` is `null` when the API omits it or sends null. Any other string stays unchanged. Pass that string to `getImage`. ```ts const today = await bibleClient.getVOTD(1, { language_ranges: ['en', 'es'] }); diff --git a/packages/core/src/__tests__/search-images.test.ts b/packages/core/src/__tests__/search-images.test.ts index 8c497522f..b9814088d 100644 --- a/packages/core/src/__tests__/search-images.test.ts +++ b/packages/core/src/__tests__/search-images.test.ts @@ -207,10 +207,10 @@ describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('searchImages', () => { /Unexpected search-images API response/, ); await expect(searchImages(api, 'faith', 111, { pageToken: 'empty-body' })).rejects.toThrow( - /Unexpected search-images API response/, + 'search-images returned no content; expected a JSON response', ); await expect(searchImages(api, 'faith', 111, { pageToken: 'no-content' })).rejects.toThrow( - /Unexpected search-images API response/, + 'search-images returned no content; expected a JSON response', ); try { diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index a620a4196..526dc8fec 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -223,7 +223,9 @@ export class BibleClient { /** * Fetches the verse of the day calendar for an entire year. * @param options Optional `language_ranges`. When omitted, the request sends no language query. - * When set, the request repeats `language_ranges[]` in that order. + * Ranges are trimmed, lowercased, and normalized from underscores to hyphens. + * Invalid Basic Language Ranges are rejected. Valid ranges retain their order + * as repeated `language_ranges[]` parameters. * @returns Calendar rows for the year. Each row keeps `day` and `passage_id`. * `image_id` is a string, or `null` when the API omits it or sends null. */ @@ -235,6 +237,9 @@ export class BibleClient { * Fetches the verse of the day for one day of the year. * @param day The day of the year (1-366). * @param options Optional `language_ranges`. When omitted, the request sends no language query. + * Ranges are trimmed, lowercased, and normalized from underscores to hyphens. + * Invalid Basic Language Ranges are rejected. Valid ranges retain their order + * as repeated `language_ranges[]` parameters. * @returns The day, `passage_id`, and `image_id`. `image_id` is `null` when the API omits it or sends null. * @example * ```ts diff --git a/packages/core/src/schemas/votd.ts b/packages/core/src/schemas/votd.ts index cadbc12d5..11fe04f31 100644 --- a/packages/core/src/schemas/votd.ts +++ b/packages/core/src/schemas/votd.ts @@ -17,7 +17,6 @@ export const VOTDSchema = z.object({ }); export type VOTD = Readonly>; -export type VOTDInput = VOTD; export const VOTDRecordWireSchema = z.pipe( VOTDSchema, diff --git a/packages/core/src/search-images.ts b/packages/core/src/search-images.ts index 41751fc5e..d0af86f64 100644 --- a/packages/core/src/search-images.ts +++ b/packages/core/src/search-images.ts @@ -40,6 +40,9 @@ export async function searchImages( } const response = await client.get('/v1/search-images', params); + if (response === '' || response === undefined) { + throw new Error('search-images returned no content; expected a JSON response'); + } const parsed = SearchImagesWireSchema.safeParse(response); if (!parsed.success) { throw new Error(`Unexpected search-images API response: ${parsed.error.message}`); diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index f86e128c1..3c014041d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -15,7 +15,7 @@ export type { PassageAttribution, PassageStylesheet, } from '../schemas/passage-display'; -export type { VOTD, VOTDInput, VOTDResponse } from '../schemas/votd'; +export type { VOTD, VOTDResponse } from '../schemas/votd'; export type { BibleIndex, BibleIndexBook,