Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
746db68
feat(core): add searchUnified for GET /v1/search-unified
cursoragent Oct 14, 2026
afd7074
test(core): cover searchUnified fields, mapping, and rejection
cursoragent Oct 14, 2026
1ac949f
chore(core): drop redundant searchUnified schema comments
cursoragent Oct 14, 2026
a0dff0a
fix(ui): re-export searchUnified from package root
cursoragent Oct 14, 2026
d183dcb
chore: raise core full-barrel size budget for searchUnified
cursoragent Oct 14, 2026
9c2c641
Merge branch 'main' into caml/ype-6188-search-unified-1037
camrun91 Oct 15, 2026
56f4ed8
fix(core): harden searchUnified optional sections and image hits
cursoragent Oct 16, 2026
b43364b
test(core): drop SAFETY cast in searchUnified fields rejection
cursoragent Oct 16, 2026
dda3064
fix(core): make SearchUnified field coverage assert fail on mismatch
cursoragent Oct 16, 2026
81048e4
feat(example): add searchUnified smoke page
cursoragent Oct 19, 2026
45548f7
fix(example): pin searchUnified results to submitted fields
cursoragent Oct 19, 2026
dc951f8
feat(search): add unified search hook and validate API contract
ampagent Oct 20, 2026
aae052b
chore(ci): allow measured unified-search hook bundle growth
ampagent Oct 20, 2026
70a7489
feat(search): ship unified search without Images beta scope
ampagent Oct 20, 2026
99e5d80
Merge branch 'main' into caml/ype-6188-search-unified-1037
camrun91 Oct 21, 2026
c2a6465
fix(search): address PR #446 self-review on unified search
camrun91 Oct 21, 2026
76c7566
fix(hooks): make unified search mock subtopics mutable for tsc
camrun91 Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/search-unified-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-core': minor
---

Add `searchUnified` for `GET /v1/search-unified`, returning one unpaginated snapshot of top verse references and related topics.
8 changes: 8 additions & 0 deletions .changeset/unified-search-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@youversion/platform-core': patch
'@youversion/platform-react-hooks': minor
---

Add useUnifiedSearch for loading top verse references and related topics through the unified-search core client, without changing the existing paginated Bible search flow.

Validate unified-search request intents and required response fields against the API contract while preserving unknown response intent strings.
5 changes: 5 additions & 0 deletions .changeset/unified-search-ui-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': minor
---

Re-export unified search client helpers from the UI package entry point.
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export default [
{
name: 'core / full barrel (esm)',
path: 'packages/core/dist/index.js',
limit: '21.5 KB',
limit: '23.7 KB',
ignore: ['jsdom'],
},
{
Expand All @@ -28,7 +28,7 @@ export default [
{
name: 'hooks / full barrel',
path: 'packages/hooks/dist/index.js',
limit: '27 KB',
limit: '29.8 KB',
ignore: ['react', 'react-dom', '@tanstack/react-query'],
},
{
Expand Down
10 changes: 9 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ const trending = await searchClient.getTrendingQueries('en');
const verses = await searchClient.searchVerses('faith', versions.data[0].id, {
pageSize: 10,
});
console.log(trending.queries[0]?.text, verses.verses[0]?.id);
const unified = await searchClient.searchUnified('psalm 23', versions.data[0].id, 'en', {
fields: ['verses', 'topics'],
});
console.log(trending.queries[0]?.text, verses.verses[0]?.id, unified.topics[0]?.text);
```

Unified search returns one snapshot of top results, not the entire verse result
set. Omit `fields` for all available kinds, or pass a nonempty selection.
`userIntent` accepts `unknown`, `topical`, `text`, or `reference`; response intent
strings remain open-ended.

### Display Bible HTML

Use `getPassageDisplay` when you need transformed HTML together with current
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
"!dist/search-verses.cjs",
"!dist/search-topics.js",
"!dist/search-topics.cjs",
"!dist/search-unified.js",
"!dist/search-unified.cjs",
"!dist/highlights.js",
"!dist/highlights.cjs",
"!dist/organizations.js",
Expand Down
311 changes: 311 additions & 0 deletions packages/core/src/__tests__/search-unified.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
import { describe, it, expect, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient } from '../client';
import { SearchClient } from '../search';
import { server } from './setup';

const apiHost = process.env.YVP_API_HOST;

function createSearchClient(): SearchClient {
const apiClient = new ApiClient({
apiHost,
appKey: 'test-app',
installationId: 'test-installation',
});
return new SearchClient(apiClient);
}

describe('SearchClient.searchUnified', () => {
it('requests verses only, maps references, and sends no paging params', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('query')).toBe('psalm 23');
expect(url.searchParams.get('bible_id')).toBe('111');
expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en']);
expect(url.searchParams.getAll('fields[]')).toEqual(['verses']);
expect(url.searchParams.get('user_intent')).toBe('topical');
expect(url.searchParams.has('page_size')).toBe(false);
expect(url.searchParams.has('page_token')).toBe(false);
return HttpResponse.json({
verses: [{ reference: 'PSA.23.1' }],
topics: [],
user_intent: null,
did_you_mean: ['psalm'],
search_instead_for: 'psalm 23',
});
}),
);

const result = await searchClient.searchUnified('psalm 23', 111, 'en', {
fields: ['verses'],
userIntent: 'topical',
});

expect(result).toEqual({
verses: [{ id: 'PSA.23.1' }],
topics: [],
didYouMean: ['psalm'],
searchInsteadFor: 'psalm 23',
});
expect(result).not.toHaveProperty('userIntent');
});

it('preserves nullable metadata and topic IDs for a topics-only snapshot', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.getAll('fields[]')).toEqual(['topics']);
return HttpResponse.json({
verses: [],
topics: [{ id: null, text: 'hope', subtopics: [] }],
user_intent: null,
did_you_mean: [],
search_instead_for: null,
});
}),
);

const result = await searchClient.searchUnified('psalm 23', 111, 'en', {
fields: ['topics'],
});

expect(result).toEqual({
verses: [],
topics: [{ id: null, text: 'hope', subtopics: [] }],
didYouMean: [],
searchInsteadFor: null,
});
});

it('drops a malformed verse and keeps valid verses and topics', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.getAll('fields[]')).toEqual(['verses', 'topics']);
expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en', 'es']);
expect(url.searchParams.has('user_intent')).toBe(false);
expect(url.searchParams.has('page_size')).toBe(false);
expect(url.searchParams.has('page_token')).toBe(false);
return HttpResponse.json({
verses: [{ reference: 'JHN.6.35' }, { reference: 'BAD' }],
topics: [{ id: 7, text: 'faith', subtopics: [] }],
user_intent: 'topical',
did_you_mean: [],
search_instead_for: null,
});
}),
);

const result = await searchClient.searchUnified('faith', 111, ['en', 'es'], {
fields: ['verses', 'topics'],
});

expect(result).toEqual({
verses: [{ id: 'JHN.6.35' }],
topics: [{ id: 7, text: 'faith', subtopics: [] }],
userIntent: 'topical',
didYouMean: [],
searchInsteadFor: null,
});
});

it('omits fields[] when fields is unset and normalizes language ranges', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('query')).toBe('hope');
expect(url.searchParams.get('bible_id')).toBe('111');
expect(url.searchParams.getAll('language_ranges[]')).toEqual(['en-us', 'es']);
expect(url.searchParams.has('fields[]')).toBe(false);
expect(url.searchParams.has('user_intent')).toBe(false);
expect(url.searchParams.has('page_size')).toBe(false);
expect(url.searchParams.has('page_token')).toBe(false);
return HttpResponse.json({
verses: [],
topics: [{ id: 42, text: 'hope', subtopics: ['trust'] }],
did_you_mean: [],
user_intent: null,
search_instead_for: null,
});
}),
);

const result = await searchClient.searchUnified('hope', 111, ['en_US', 'es']);

expect(result).toEqual({
verses: [],
topics: [{ id: 42, text: 'hope', subtopics: ['trust'] }],
didYouMean: [],
searchInsteadFor: null,
});
expect(result).not.toHaveProperty('userIntent');
});

it('sends the first of each repeated field in caller order', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.getAll('fields[]')).toEqual(['topics', 'verses']);
return HttpResponse.json({
verses: [],
topics: [],
user_intent: null,
did_you_mean: [],
search_instead_for: null,
});
}),
);

const result = await searchClient.searchUnified('faith', 111, 'en', {
fields: ['topics', 'verses', 'topics'],
});

expect(result).toEqual({
verses: [],
topics: [],
didYouMean: [],
searchInsteadFor: null,
});
});

it('rejects empty fields and invalid input before networking', async () => {
const searchClient = createSearchClient();
server.use(
http.get(`https://${apiHost}/v1/search-unified`, () =>
HttpResponse.json({
verses: [],
topics: [],
user_intent: null,
did_you_mean: [],
search_instead_for: null,
}),
),
);
const fetchSpy = vi.spyOn(global, 'fetch');

await expect(searchClient.searchUnified('', 111, 'en')).rejects.toThrow(
'Query must be between 1 and 100 characters',
);
await expect(searchClient.searchUnified('faith', 0, 'en')).rejects.toThrow(
'Version ID must be a positive integer',
);
await expect(searchClient.searchUnified('faith', 111, [])).rejects.toThrow(
'At least one language range is required',
);
await expect(searchClient.searchUnified('faith', 111, 'en', { fields: [] })).rejects.toThrow(
'At least one search field is required',
);
await expect(
searchClient.searchUnified('faith', 111, 'en', {
// @ts-expect-error - verifies runtime validation for unsafe JavaScript callers
fields: ['nope'],
}),
).rejects.toThrow('Search field must be verses or topics');
await expect(
searchClient.searchUnified('faith', 111, 'en', {
// @ts-expect-error - verifies runtime validation for unsafe JavaScript callers
userIntent: 'not-a-known-intent',
}),
).rejects.toThrow();

expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});

it('preserves future response intents and defaults omitted wire fields for filtered search', async () => {
const searchClient = createSearchClient();
const snapshot = {
verses: [{ reference: 'JHN.3.16' }],
topics: [{ id: null, text: 'love', subtopics: [] }],
user_intent: 'future-intent',
did_you_mean: [],
search_instead_for: null,
};

server.use(http.get(`https://${apiHost}/v1/search-unified`, () => HttpResponse.json(snapshot)));

expect(await searchClient.searchUnified('love', 111, 'en')).toEqual({
verses: [{ id: 'JHN.3.16' }],
topics: [{ id: null, text: 'love', subtopics: [] }],
userIntent: 'future-intent',
didYouMean: [],
searchInsteadFor: null,
});

for (const field of ['verses', 'topics', 'user_intent', 'search_instead_for'] as const) {
server.use(
http.get(`https://${apiHost}/v1/search-unified`, () =>
HttpResponse.json({ ...snapshot, [field]: undefined }),
),
);
const result = await searchClient.searchUnified('love', 111, 'en', { fields: ['topics'] });
expect(result.verses).toEqual(field === 'verses' ? [] : [{ id: 'JHN.3.16' }]);
expect(result.topics).toEqual(
field === 'topics' ? [] : [{ id: null, text: 'love', subtopics: [] }],
);
expect(result.didYouMean).toEqual([]);
expect(result.searchInsteadFor).toBeNull();
if (field === 'user_intent') {
expect(result).not.toHaveProperty('userIntent');
} else {
expect(result.userIntent).toBe('future-intent');
}
}
});

it('drops malformed verse and topic hits without failing the response', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, () =>
HttpResponse.json({
verses: [{ reference: 'JHN.3.16' }, { reference: null }, { reference: 'BAD' }],
topics: [
{ id: null, text: 'love', subtopics: [] },
{ text: 'missing id', subtopics: [] },
{ id: null, text: 'hope', subtopics: 'not-an-array' },
],
did_you_mean: [],
}),
),
);

expect(await searchClient.searchUnified('love', 111, 'en')).toEqual({
verses: [{ id: 'JHN.3.16' }],
topics: [{ id: null, text: 'love', subtopics: [] }],
didYouMean: [],
searchInsteadFor: null,
});
});

it('defaults did_you_mean when omitted on the wire', async () => {
const searchClient = createSearchClient();

server.use(
http.get(`https://${apiHost}/v1/search-unified`, () =>
HttpResponse.json({
verses: [],
topics: [],
}),
),
);

expect(await searchClient.searchUnified('faith', 111, 'en')).toEqual({
verses: [],
topics: [],
didYouMean: [],
searchInsteadFor: null,
});
});
});
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ export {
export { LanguagesClient } from './languages';
export { getLanguage } from './languages-language';
export { getLanguages, type GetLanguagesOptions } from './languages-list';
export { SearchClient, type SearchVersesOptions } from './search';
export { SearchClient, type SearchUnifiedOptions, type SearchVersesOptions } from './search';
export { getSuggestedQueries, getTrendingQueries } from './search-queries';
export { searchVerses } from './search-verses';
export { searchTopics } from './search-topics';
export { searchUnified } from './search-unified';
export {
MAX_SEARCH_QUERY_GRAPHEMES,
clampSearchText,
Expand Down
Loading
Loading