Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/unified-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-core': minor
'@youversion/platform-react-hooks': minor
'@youversion/platform-react-ui': minor
---

Add unified search for top verse references and related topics through SearchClient.searchUnified, the standalone searchUnified function, and the useUnifiedSearch React hook. Existing paginated verse search behavior is unchanged.
25 changes: 25 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,31 @@ const verses = await searchClient.searchVerses('faith', versions.data[0].id, {
console.log(trending.queries[0]?.text, verses.verses[0]?.id);
```

### Unified search

Use `searchUnified` to fetch top verse references and related topics in one
`GET /v1/search-unified` request:

```ts
const results = await searchClient.searchUnified('faith', 111, ['en'], {
userIntent: 'topical',
fields: ['verses', 'topics'],
});
console.log(results.verses[0]?.id, results.topics[0]?.text);
```

Omit `fields` or pass an empty array to include all kinds. The response includes
`didYouMean`, `searchInsteadFor`, and an optional `userIntent`. This unpaginated
snapshot contains top results, not the entire verse result set. Use `searchVerses`
for verse pagination. Verse hits contain passage IDs, not passage text; resolve
text through `BibleClient.getPassage`.

You can also import `searchUnified` and call it with an `ApiClient` as the first
argument, followed by the same arguments as the client method.

Use supported search language codes such as `en`. Valid language-range syntax
does not guarantee backend support; the SDK does not add fallback ranges.

### 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
126 changes: 126 additions & 0 deletions packages/core/src/__tests__/search-unified.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, it } from 'vitest';
import { http, HttpResponse } from 'msw';
import { ApiClient, SearchClient, searchUnified } from '../index';
import { server } from './setup';

const apiHost = process.env.YVP_API_HOST;

function createApiClient(): ApiClient {
return new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' });
}

it('returns a unified snapshot from one request, mapping hits and metadata without pagination', async () => {
const requests: URL[] = [];
server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
requests.push(new URL(request.url));
return HttpResponse.json({
verses: [{ reference: 'JHN.6.35' }, { reference: 'BAD' }],
topics: [
{ id: 42, text: 'faith', subtopics: ['trust'] },
{ id: null, text: 'belief', subtopics: [] },
],
user_intent: 'future-intent',
did_you_mean: ['faith'],
search_instead_for: 'faith',
});
}),
);

const result = await new SearchClient(createApiClient()).searchUnified(
' faith ',
111,
['en_US', 'es'],
{ userIntent: 'topical', fields: ['topics', 'verses'] },
);
expect(result).toEqual({
verses: [{ id: 'JHN.6.35' }],
topics: [
{ id: 42, text: 'faith', subtopics: ['trust'] },
{ id: null, text: 'belief', subtopics: [] },
],
userIntent: 'future-intent',
didYouMean: ['faith'],
searchInsteadFor: 'faith',
});
expect(requests).toHaveLength(1);
const params = requests[0]!.searchParams;
expect(params.get('query')).toBe('faith');
expect(params.get('bible_id')).toBe('111');
expect(params.getAll('language_ranges[]')).toEqual(['en-us', 'es']);
expect(params.get('user_intent')).toBe('topical');
expect(params.getAll('fields[]')).toEqual(['topics', 'verses']);
expect(params.has('page_size')).toBe(false);
expect(params.has('page_token')).toBe(false);
});

it('omits default intent and empty fields, accepting empty results and nullable metadata', async () => {
const requests: URL[] = [];
server.use(
http.get(`https://${apiHost}/v1/search-unified`, ({ request }) => {
requests.push(new URL(request.url));
return HttpResponse.json({
verses: [],
topics: [],
user_intent: null,
did_you_mean: [],
search_instead_for: null,
});
}),
);
const client = createApiClient();
for (const options of [undefined, { fields: [] }]) {
expect(await searchUnified(client, 'faith', 111, '*', options)).toEqual({
verses: [],
topics: [],
didYouMean: [],
searchInsteadFor: null,
});
}
for (const url of requests) {
expect(url.searchParams.getAll('language_ranges[]')).toEqual(['*']);
expect(url.searchParams.has('fields[]')).toBe(false);
expect(url.searchParams.has('user_intent')).toBe(false);
}
});

it('rejects invalid inputs before networking and rejects malformed unified responses', async () => {
let requests = 0;
let response = HttpResponse.json({});
server.use(
http.get(`https://${apiHost}/v1/search-unified`, () => {
requests += 1;
return response;
}),
);
const client = new SearchClient(createApiClient());
await expect(client.searchUnified(' ', 111, 'en')).rejects.toThrow(/Query must/);
await expect(client.searchUnified('a'.repeat(101), 111, 'en')).rejects.toThrow(/Query must/);
await expect(client.searchUnified('faith', 0, 'en')).rejects.toThrow(/Version ID/);
await expect(client.searchUnified('faith', 111, [])).rejects.toThrow(/language range/);
await expect(client.searchUnified('faith', 111, 'en-abcdefghi')).rejects.toThrow(
/Language range/,
);
expect(requests).toBe(0);

const valid = {
verses: [],
topics: [],
user_intent: null,
did_you_mean: [],
search_instead_for: null,
};
for (const malformed of [
{},
{ ...valid, topics: null },
{ ...valid, verses: [{ reference: 7 }] },
{ ...valid, topics: [{ text: 'faith', subtopics: [] }] },
{ ...valid, user_intent: undefined },
{ ...valid, search_instead_for: undefined },
]) {
response = HttpResponse.json(malformed);
await expect(client.searchUnified('faith', 111, 'en')).rejects.toThrow(
/Unexpected search-unified API response/,
);
}
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { SearchClient, 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
43 changes: 43 additions & 0 deletions packages/core/src/schemas/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,46 @@ export const SearchVersesOptionsSchema = z.object({
});

export type SearchVersesOptions = z.infer<typeof SearchVersesOptionsSchema>;

export const SearchUnifiedOptionsSchema = z.object({
/** Omit to use the service default, `unknown`. */
userIntent: z.optional(z.enum(KNOWN_SEARCH_USER_INTENTS)),
/** Result kinds to include. Omit or pass an empty array for all kinds. */
fields: z.optional(z.array(z.enum(['verses', 'topics']))),
});

export type SearchUnifiedOptions = z.infer<typeof SearchUnifiedOptionsSchema>;

/** Top results per kind, not the entire verse result set. No pagination is available. */
export const SearchUnifiedResponseSchema = z.object({
verses: z.array(SearchVerseHitSchema),
topics: z.array(SearchTopicSchema),
userIntent: z.optional(z.string()),
didYouMean: z.array(z.string()),
searchInsteadFor: z.nullable(z.string()),
});

export type SearchUnifiedResponse = Readonly<z.infer<typeof SearchUnifiedResponseSchema>>;

export const SearchUnifiedWireSchema = z.object({
verses: z.array(SearchVerseHitWireSchema),
topics: z.array(z.extend(SearchTopicWireSchema, { id: z.nullable(z.int()) })),
user_intent: z.nullable(z.string()),
did_you_mean: z.array(z.string()),
search_instead_for: z.nullable(z.string()),
});

export type SearchUnifiedWire = z.infer<typeof SearchUnifiedWireSchema>;

export function toSearchUnifiedResponse(wire: SearchUnifiedWire): SearchUnifiedResponse {
const response: SearchUnifiedResponse = {
verses: wire.verses.map(toSearchVerseHit).filter((hit): hit is SearchVerseHit => hit !== null),
topics: wire.topics.map(toSearchTopic),
didYouMean: wire.did_you_mean,
searchInsteadFor: wire.search_instead_for,
};
if (wire.user_intent != null) {
return { ...response, userIntent: wire.user_intent };
}
return response;
}
53 changes: 53 additions & 0 deletions packages/core/src/search-unified.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ApiClient } from './client';
import { BibleVersionIdSchema } from './schemas/version';
import {
parseLanguageRanges,
SearchTextQuerySchema,
SearchUnifiedOptionsSchema,
SearchUnifiedWireSchema,
toSearchUnifiedResponse,
type SearchUnifiedOptions,
type SearchUnifiedResponse,
} from './schemas/search';

type SearchUnifiedQuery = {
query: string;
bible_id: number;
'language_ranges[]': string[];
user_intent?: string;
'fields[]'?: string[];
};

/** Fetches one unpaginated snapshot of top verse references and related topics. */
export async function searchUnified(
client: ApiClient,
query: string,
versionId: number,
languageRanges: string | string[],
options: SearchUnifiedOptions = {},
): Promise<SearchUnifiedResponse> {
const parsedQuery = SearchTextQuerySchema.parse(query);
BibleVersionIdSchema.parse(versionId);
const parsedLanguageRanges = parseLanguageRanges(languageRanges);
const parsedOptions = SearchUnifiedOptionsSchema.parse(options);

const params: SearchUnifiedQuery = {
query: parsedQuery,
bible_id: versionId,
'language_ranges[]': parsedLanguageRanges,
};
if (parsedOptions.userIntent !== undefined) {
params.user_intent = parsedOptions.userIntent;
}
if (parsedOptions.fields?.length) {
params['fields[]'] = parsedOptions.fields;
}

const response = await client.get<unknown>('/v1/search-unified', params);

const parsed = SearchUnifiedWireSchema.safeParse(response);
if (!parsed.success) {
throw new Error(`Unexpected search-unified API response: ${parsed.error.message}`);
}
return toSearchUnifiedResponse(parsed.data);
}
29 changes: 27 additions & 2 deletions packages/core/src/search.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { ApiClient } from './client';
import { getSuggestedQueries, getTrendingQueries } from './search-queries';
import { searchTopics } from './search-topics';
import { searchUnified } from './search-unified';
import { searchVerses, type SearchVersesOptions } from './search-verses';
import type { SearchQueries, SearchTopicsResponse, SearchVersesResponse } from './schemas/search';
import type {
SearchQueries,
SearchTopicsResponse,
SearchUnifiedOptions,
SearchUnifiedResponse,
SearchVersesResponse,
} from './schemas/search';

export type { SearchVersesOptions };
export type { SearchUnifiedOptions, SearchVersesOptions };

/**
* Client for YouVersion Platform Search API endpoints (`/v1/*`).
Expand Down Expand Up @@ -65,4 +72,22 @@ export class SearchClient {
): Promise<SearchTopicsResponse> {
return searchTopics(this.client, query, languageRanges);
}

/**
* Returns one unpaginated snapshot of top verse references and related topics.
* Resolve passage text through the licensed-content endpoints. Use `searchVerses`
* to paginate verse results; this snapshot is not the entire verse result set.
* @param query Search text (1–100 characters).
* @param versionId Bible version identifier (sent as `bible_id`).
* @param languageRanges Ordered Basic Language Ranges, normalized as in `searchTopics`.
* @param options Optional intent and result kinds. Omit fields for all kinds.
*/
async searchUnified(
query: string,
versionId: number,
languageRanges: string | string[],
options?: SearchUnifiedOptions,
): Promise<SearchUnifiedResponse> {
return searchUnified(this.client, query, versionId, languageRanges, options);
}
}
2 changes: 2 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type {
SearchQuery,
SearchTopic,
SearchTopicsResponse,
SearchUnifiedOptions,
SearchUnifiedResponse,
SearchVerseHit,
SearchVersesOptions,
SearchVersesResponse,
Expand Down
1 change: 1 addition & 0 deletions packages/core/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineConfig({
'src/search-queries.ts',
'src/search-verses.ts',
'src/search-topics.ts',
'src/search-unified.ts',
'src/highlights.ts',
'src/organizations.ts',
'src/data-exchange.ts',
Expand Down
26 changes: 26 additions & 0 deletions packages/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,32 @@ function App() {
}
```

## Unified search

Inside `YouVersionProvider`, use `useUnifiedSearch` for an unpaginated snapshot
of top verse references and related topics. Search requires an app key authorized
for Search.

```tsx
import { useUnifiedSearch } from '@youversion/platform-react-hooks';

function SearchResults({ query }: { query: string }) {
const { data, loading, error, refetch } = useUnifiedSearch(
{ query, versionId: 111, languageRanges: ['en'] },
{ enabled: query.trim().length > 0 },
);
if (loading) return <div>Loading...</div>;
if (error) return <button onClick={refetch}>Retry</button>;
return <ul>{data?.topics.map((topic) => <li key={topic.text}>{topic.text}</li>)}</ul>;
}
```

The props also accept `userIntent` and `fields` (`verses` and/or `topics`). Omit
`fields` or pass an empty array for all kinds. You control when to submit or
debounce the query. This hook does not load suggestions or paginate. The existing
`useBibleSearch` hook keeps its suggestion and paginated verse-search behavior.
Verse hits contain passage IDs, not passage text.

## Documentation and API Reference
* [developers.youversion.com/sdks/react](https://developers.youversion.com/sdks/react)

Expand Down
5 changes: 5 additions & 0 deletions packages/hooks/src/hook-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { UseLanguagesResult } from './useLanguages';
import type { UseOrganizationsResult } from './useOrganizations';
import type { UsePassageProps, UsePassageResult } from './usePassage';
import type { UseBibleSearchProps, UseBibleSearchResult } from './useBibleSearch';
import type { UseUnifiedSearchProps, UseUnifiedSearchResult } from './useUnifiedSearch';
import type { UseVersionResult } from './useVersion';
import type { UseVersionsOptions, UseVersionsResult } from './useVersions';
import type { UseVerseOfTheDayResult } from './useVOTD';
Expand Down Expand Up @@ -41,6 +42,10 @@ export type HookOverrides = {
useOrganizations?: (organizationIds: (string | null | undefined)[]) => UseOrganizationsResult;
usePassage?: (props: UsePassageProps) => UsePassageResult;
useBibleSearch?: (props: UseBibleSearchProps) => UseBibleSearchResult;
useUnifiedSearch?: (
props: UseUnifiedSearchProps,
apiOptions?: UseApiDataOptions,
) => UseUnifiedSearchResult;
useTheme?: () => 'light' | 'dark';
useVersion?: (versionId: number, options?: UseApiDataOptions) => UseVersionResult;
useVersions?: (
Expand Down
Loading
Loading