Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ype-4813-reader-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': minor
---

Hosts can pass `locale` on `YouVersionProvider` to set SDK UI language and `Accept-Language` (including Verse of the Day copy in Expo WebViews), and `defaultLanguageId` / `languageId` on `BibleReader.Root` to seed the version picker. App locale and Bible language stay separate: `locale` does not pick a default Bible translation.
14 changes: 14 additions & 0 deletions docs/i18n-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ const { t } = useTranslation(undefined, { i18n });

Never hardcode user-facing text in JSX attributes (`aria-label`, `title`, `placeholder`, `alt`) or visible copy.

## Host-set language

By default the UI language follows `navigator.languages`. Pass `locale` on `YouVersionProvider` to set it explicitly — for example the React Native Expo SDK forwarding its provider `locale` into a WebView:

```tsx
<YouVersionProvider appKey="YOUR_APP_KEY" locale="es">
<VerseOfTheDay />
</YouVersionProvider>
```

Regional tags such as `es-MX` resolve to a bundled locale (`es`). Unsupported tags fall back to English. Omit `locale` to keep browser detection. `locale` also sets `Accept-Language` on API calls unless the host already set that header.

This is app locale, not Bible translation language. Seed the version picker with `defaultLanguageId` on `BibleReader.Root`. Do not map `locale` to a Bible language. No new translation keys are needed for this; existing bundles (including Spanish `verseOfTheDay`) are used as-is.

## Local checks

```bash
Expand Down
4 changes: 4 additions & 0 deletions examples/vite-react/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
VITE_YVP_APP_KEY=""
VITE_YVP_API_HOST="api.youversion.com"
VITE_YVP_AUTH_REDIRECT_URL="http://localhost:5173"
# Optional. SDK UI language (BCP-47). Leave unset to follow the browser.
# VITE_YVP_LOCALE="es"
# Optional. Seeds the Reader version picker Bible language. Distinct from locale.
# VITE_YVP_DEFAULT_LANGUAGE_ID="es"
1 change: 1 addition & 0 deletions examples/vite-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A demo app showcasing `@youversion/platform-react-ui` components.
```bash
cp .env.example .env.local
# Add your YouVersion App Key to .env.local
# Optional: VITE_YVP_LOCALE and VITE_YVP_DEFAULT_LANGUAGE_ID (e.g. es)
pnpm install
pnpm dev
```
Expand Down
2 changes: 2 additions & 0 deletions examples/vite-react/src/ThemedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function ThemedApp() {
const appKey = import.meta.env.VITE_YVP_APP_KEY;
const apiHost = import.meta.env.VITE_YVP_API_HOST ?? 'api.youversion.com';
const authRedirectUrl = import.meta.env.VITE_YVP_AUTH_REDIRECT_URL ?? window.location.origin;
const locale = import.meta.env.VITE_YVP_LOCALE?.trim() || undefined;

return (
<YouVersionProvider
Expand All @@ -18,6 +19,7 @@ export default function ThemedApp() {
includeAuth
authRedirectUrl={authRedirectUrl}
appName="SDK Demo"
locale={locale}
>
<App />
</YouVersionProvider>
Expand Down
9 changes: 8 additions & 1 deletion examples/vite-react/src/pages/BibleReaderPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { BibleReader } from '@youversion/platform-react-ui';

export function BibleReaderPage() {
const defaultLanguageId = import.meta.env.VITE_YVP_DEFAULT_LANGUAGE_ID?.trim() || undefined;

return (
<div className="h-[calc(100vh-3.5rem)]">
<BibleReader.Root defaultBook="JHN" defaultChapter="1" defaultVersionId={3034}>
<BibleReader.Root
defaultBook="JHN"
defaultChapter="1"
defaultVersionId={3034}
defaultLanguageId={defaultLanguageId}
>
<BibleReader.Content />
<BibleReader.Toolbar />
</BibleReader.Root>
Expand Down
13 changes: 13 additions & 0 deletions examples/vite-react/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_YVP_APP_KEY?: string;
readonly VITE_YVP_API_HOST?: string;
readonly VITE_YVP_AUTH_REDIRECT_URL?: string;
readonly VITE_YVP_LOCALE?: string;
readonly VITE_YVP_DEFAULT_LANGUAGE_ID?: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
8 changes: 8 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ function App() {
}
```

Optional `locale` sets bundled UI copy (Verse of the Day heading, buttons, etc.) and `Accept-Language` instead of following the browser language. Regional tags like `es-MX` resolve to a bundled locale. This is app language, not Bible translation language — seed the version picker with `defaultLanguageId` on `BibleReader.Root`.

```tsx
<YouVersionProvider appKey="YOUR_APP_KEY" locale="es">
<VerseOfTheDay />
</YouVersionProvider>
```

### Limit which Bible versions the SDK uses

By default the version picker offers Bible versions in every available language. Limit that with `permittedLanguageTags`, `permittedVersionIds`, and `excludedVersionIds` on `YouVersionProvider`. A version must satisfy every list that is set. Exclusion wins if an id is in both permit and exclude lists. Unset means no restriction; an empty permit list permits nothing.
Expand Down
86 changes: 86 additions & 0 deletions packages/ui/src/components/YouVersionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderToString } from 'react-dom/server';
import React, { useContext } from 'react';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionContext } from '@youversion/platform-react-hooks';
import { YouVersionProvider } from '@/components/YouVersionProvider';
import i18n from '@/i18n';

function AdditionalHeadersProbe(): React.ReactElement {
const headers = useContext(YouVersionContext)?.additionalHeaders;
Expand Down Expand Up @@ -36,6 +38,50 @@ describe('UI YouVersionProvider', () => {
expect(screen.getByTestId('headers').textContent).toBe('none');
});

it('sends Accept-Language from locale', () => {
render(
<YouVersionProvider appKey="test-key" locale="es-MX">
<AdditionalHeadersProbe />
</YouVersionProvider>,
);

expect(screen.getByTestId('headers').textContent).toBe(
JSON.stringify({ 'Accept-Language': 'es-MX' }),
);
});

it('lets additionalHeaders override Accept-Language from locale', () => {
render(
<YouVersionProvider
appKey="test-key"
locale="es"
additionalHeaders={{ 'Accept-Language': 'fr', 'X-Custom': '1' }}
>
<AdditionalHeadersProbe />
</YouVersionProvider>,
);

expect(screen.getByTestId('headers').textContent).toBe(
JSON.stringify({ 'Accept-Language': 'fr', 'X-Custom': '1' }),
);
});

it('lets additionalHeaders override Accept-Language from locale regardless of header casing', () => {
render(
<YouVersionProvider
appKey="test-key"
locale="es-MX"
additionalHeaders={{ 'accept-language': 'fr', 'X-Custom': '1' }}
>
<AdditionalHeadersProbe />
</YouVersionProvider>,
);

expect(screen.getByTestId('headers').textContent).toBe(
JSON.stringify({ 'accept-language': 'fr', 'X-Custom': '1' }),
);
});

it('mirrors appName and signInPromptMessage onto the UI-bundled config', () => {
YouVersionPlatformConfiguration.appName = undefined;
YouVersionPlatformConfiguration.signInPromptMessage = undefined;
Expand Down Expand Up @@ -81,4 +127,44 @@ describe('UI YouVersionProvider', () => {
errorSpy.mockRestore();
},
);

it('uses locale instead of the browser language', async () => {
vi.stubGlobal('navigator', {
language: 'en-US',
languages: ['en-US', 'en'],
});

const { rerender } = render(
<YouVersionProvider appKey="test-key" locale="es">
<div />
</YouVersionProvider>,
);

expect(i18n.language).toBe('es');

rerender(
<YouVersionProvider appKey="test-key" locale="es-MX">
<div />
</YouVersionProvider>,
);

expect(i18n.language).toBe('es');

await i18n.changeLanguage('en');
vi.unstubAllGlobals();
});

it('applies locale during SSR without waiting for layout effects', async () => {
await i18n.changeLanguage('en');

renderToString(
<YouVersionProvider appKey="test-key" locale="es">
<div />
</YouVersionProvider>,
);

expect(i18n.language).toBe('es');

await i18n.changeLanguage('en');
});
});
54 changes: 45 additions & 9 deletions packages/ui/src/components/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { type ComponentProps, Suspense, useEffect } from 'react';
import React, { type ComponentProps, Suspense, useEffect, useLayoutEffect } from 'react';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionProvider as BaseYouVersionProvider } from '@youversion/platform-react-hooks';
import { syncBrowserLanguageFromNavigator } from '@/i18n';
import { syncSdkLanguage } from '@/i18n';
import { YvStyles } from '@/lib/yv-styles';
import { YvFonts } from '@/lib/yv-fonts';
import { MissingAppKey } from '@/components/missing-app-key';
Expand All @@ -12,12 +12,38 @@ function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | '
return globalThis.window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

export function YouVersionProvider(
props: ComponentProps<typeof BaseYouVersionProvider>,
): React.ReactElement {
useEffect(() => {
syncBrowserLanguageFromNavigator();
}, []);
export type YouVersionProviderProps = ComponentProps<typeof BaseYouVersionProvider> & {
/**
* BCP-47 tag for SDK UI strings and the `Accept-Language` header on API
* calls. When omitted, UI language follows the browser and API language
* stays the server default unless the host sets `Accept-Language` in
* `additionalHeaders`.
*
* This is app locale, not Bible translation language. Seed the version
* picker with `defaultLanguageId` on `BibleReader.Root` instead of mapping
* `locale` to a Bible language.
*/
locale?: string;
};

export function YouVersionProvider({
locale,
additionalHeaders,
...props
}: YouVersionProviderProps): React.ReactElement {
const normalizedLocale = locale?.trim() || undefined;

// Layout effects never run during SSR. Apply an explicit locale during render
// so children emit the host language in the server HTML and the first client
// paint matches it. When locale is omitted, wait for the layout effect so SSR
// stays on the English fallback instead of a request-time browser language.
if (normalizedLocale) {
void syncSdkLanguage(normalizedLocale);
Comment thread
Dustin-Kelley marked this conversation as resolved.
}

useLayoutEffect(() => {
void syncSdkLanguage(normalizedLocale);
Comment thread
Dustin-Kelley marked this conversation as resolved.
}, [normalizedLocale]);
Comment thread
Dustin-Kelley marked this conversation as resolved.

// UI tsup inlines `@youversion/platform-core`, so this singleton is a different
// copy from the one hooks syncs. BibleReader reads appName / signInPromptMessage
Expand Down Expand Up @@ -60,8 +86,18 @@ export function YouVersionProvider(
);
}

let mergedHeaders = additionalHeaders;
if (normalizedLocale) {
const hostSetsAcceptLanguage = Object.keys(additionalHeaders ?? {}).some(
(key) => key.toLowerCase() === 'accept-language',
);
if (!hostSetsAcceptLanguage) {
mergedHeaders = { 'Accept-Language': normalizedLocale, ...additionalHeaders };
}
}

return (
<BaseYouVersionProvider {...props}>
<BaseYouVersionProvider {...props} additionalHeaders={mergedHeaders}>
<YvStyles />
{/* Only in this branch — the missing-app-key guard above has no key, and
without a key the gated Fonts API request would 401.
Expand Down
62 changes: 62 additions & 0 deletions packages/ui/src/components/bible-reader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ function renderWithOverrides(ui: ReactElement) {
return render(<HookOverrideProvider overrides={defaultOverrides()}>{ui}</HookOverrideProvider>);
}

function overridesRecordingVersionLanguage() {
const requestedLanguages: string[] = [];
const overrides = {
...defaultOverrides(),
useVersions: (languageRanges?: string | string[]) => {
if (languageRanges !== undefined && !Array.isArray(languageRanges)) {
requestedLanguages.push(languageRanges);
}
return {
versions: { data: [], next_page_token: null },
loading: false,
error: null,
refetch: () => undefined,
};
},
} satisfies HookOverrides;
return {
requestedLanguages,
overrides,
};
}

const mockBooks: BibleBook[] = [
{
id: 'JHN',
Expand Down Expand Up @@ -407,3 +429,43 @@ describe('BibleReader Toolbar - onChapterPickerPress', () => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
});

describe('BibleReader version picker language', () => {
it('seeds the version picker with defaultLanguageId instead of the browser language', () => {
const { overrides, requestedLanguages } = overridesRecordingVersionLanguage();

render(
<HookOverrideProvider overrides={overrides}>
<BibleReader.Root
defaultVersionId={3034}
defaultBook="JHN"
defaultChapter="1"
defaultLanguageId="es"
>
<BibleReader.Toolbar />
</BibleReader.Root>
</HookOverrideProvider>,
);

expect(requestedLanguages.includes('es')).toBe(true);
});

it('uses a controlled languageId for the version picker', () => {
const { overrides, requestedLanguages } = overridesRecordingVersionLanguage();

render(
<HookOverrideProvider overrides={overrides}>
<BibleReader.Root
defaultVersionId={3034}
defaultBook="JHN"
defaultChapter="1"
languageId="ko"
>
<BibleReader.Toolbar />
</BibleReader.Root>
</HookOverrideProvider>,
);

expect(requestedLanguages.includes('ko')).toBe(true);
});
});
Loading
Loading