From 31cf74b6e08e08bdba1d978b790bd18ef9747655 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 16:53:31 +0000 Subject: [PATCH 1/8] feat(ui): let hosts supply locale and Reader Bible language Expo DOM WebViews often report English in navigator, so the Reader could not follow the device language. YouVersionProvider now accepts locale for UI strings and Accept-Language. BibleReader.Root accepts defaultLanguageId/languageId for the version picker. App locale and Bible language stay separate. YPE-4813 --- .changeset/ype-4813-reader-locale.md | 5 +++ .../components/YouVersionProvider.test.tsx | 30 +++++++++++++ .../ui/src/components/YouVersionProvider.tsx | 37 ++++++++++++---- .../ui/src/components/bible-reader.test.tsx | 42 +++++++++++++++++++ packages/ui/src/components/bible-reader.tsx | 31 ++++++++++++++ packages/ui/src/i18n/index.test.ts | 15 +++++++ packages/ui/src/i18n/index.ts | 34 ++++++++++++--- packages/ui/src/index.ts | 2 +- 8 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 .changeset/ype-4813-reader-locale.md diff --git a/.changeset/ype-4813-reader-locale.md b/.changeset/ype-4813-reader-locale.md new file mode 100644 index 00000000..2f9f1fca --- /dev/null +++ b/.changeset/ype-4813-reader-locale.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': minor +--- + +Hosts can pass `locale` on `YouVersionProvider` to set SDK UI language and `Accept-Language`, 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. diff --git a/packages/ui/src/components/YouVersionProvider.test.tsx b/packages/ui/src/components/YouVersionProvider.test.tsx index 835c72c7..231e95db 100644 --- a/packages/ui/src/components/YouVersionProvider.test.tsx +++ b/packages/ui/src/components/YouVersionProvider.test.tsx @@ -47,6 +47,36 @@ describe('UI YouVersionProvider', () => { expect(lastCall?.additionalHeaders).toBeUndefined(); }); + it('sends Accept-Language from locale and does not forward locale to the hooks provider', () => { + render( + +
hello
+
, + ); + + const lastCall = baseProviderMock.mock.calls.at(-1)?.[0] as Record; + expect(lastCall?.locale).toBeUndefined(); + expect(lastCall?.additionalHeaders).toEqual({ 'Accept-Language': 'es-MX' }); + }); + + it('lets additionalHeaders override Accept-Language from locale', () => { + render( + +
hello
+
, + ); + + const lastCall = baseProviderMock.mock.calls.at(-1)?.[0] as Record; + expect(lastCall?.additionalHeaders).toEqual({ + 'Accept-Language': 'fr', + 'X-Custom': '1', + }); + }); + it('mirrors appName and signInPromptMessage onto the UI-bundled config', () => { YouVersionPlatformConfiguration.appName = undefined; YouVersionPlatformConfiguration.signInPromptMessage = undefined; diff --git a/packages/ui/src/components/YouVersionProvider.tsx b/packages/ui/src/components/YouVersionProvider.tsx index 59a21109..8f92aea8 100644 --- a/packages/ui/src/components/YouVersionProvider.tsx +++ b/packages/ui/src/components/YouVersionProvider.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, Suspense, useEffect } 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'; @@ -12,12 +12,30 @@ function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | ' return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } -export function YouVersionProvider( - props: ComponentProps, -): React.ReactElement { +export type YouVersionProviderProps = ComponentProps & { + /** + * 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; + useEffect(() => { - syncBrowserLanguageFromNavigator(); - }, []); + void syncSdkLanguage(normalizedLocale); + }, [normalizedLocale]); // UI tsup inlines `@youversion/platform-core`, so this singleton is a different // copy from the one hooks syncs. BibleReader reads appName / signInPromptMessage @@ -55,8 +73,13 @@ export function YouVersionProvider( ); } + let mergedHeaders = additionalHeaders; + if (normalizedLocale) { + mergedHeaders = { 'Accept-Language': normalizedLocale, ...additionalHeaders }; + } + return ( - + {/* Only in this branch — the missing-app-key guard above has no key, and without a key the gated Fonts API request would 401. diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 96b98308..d23b5fb7 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -428,3 +428,45 @@ 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', () => { + localStorage.clear(); + setupDefaultMocks(); + + render( + + + , + ); + + expect( + vi.mocked(useVersions).mock.calls.some(([languageRanges]) => languageRanges === 'es'), + ).toBe(true); + }); + + it('uses a controlled languageId for the version picker', () => { + localStorage.clear(); + setupDefaultMocks(); + + render( + + + , + ); + + expect( + vi.mocked(useVersions).mock.calls.some(([languageRanges]) => languageRanges === 'ko'), + ).toBe(true); + }); +}); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index bc45e0e5..0ac84deb 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -78,6 +78,9 @@ type BibleReaderContextType = { onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; + languageId?: string; + defaultLanguageId?: string; + onLanguageChange?: (languageId: string) => void; onSignInPress?: () => void; onSignOutPress?: () => void; onCopy?: (data: BibleReaderShareData) => void | Promise; @@ -211,6 +214,22 @@ export type RootProps = { onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; + /** + * Bible translation language for the version picker (`en`, `es`, …). + * Controlled when set with `onLanguageChange`. + * + * Distinct from `YouVersionProvider` `locale`, which is app UI language. + * Do not derive this from device locale unless the host intends the picker + * to open on that Bible language. + */ + languageId?: string; + /** + * Uncontrolled initial Bible translation language for the version picker. + * Ignored when `languageId` is set. When omitted, the picker falls back to + * the browser language, then `en`. + */ + defaultLanguageId?: string; + onLanguageChange?: (languageId: string) => void; onSignInPress?: () => void; onSignOutPress?: () => void; /** @@ -451,6 +470,9 @@ function Root({ onFootnotePress, onChapterPickerPress, onVersionPickerPress, + languageId, + defaultLanguageId, + onLanguageChange, onSignInPress, onSignOutPress, onCopy, @@ -626,6 +648,9 @@ function Root({ onFootnotePress, onChapterPickerPress, onVersionPickerPress, + languageId, + defaultLanguageId, + onLanguageChange, onSignInPress, onSignOutPress, onCopy, @@ -1358,6 +1383,9 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba background, onChapterPickerPress, onVersionPickerPress, + languageId, + defaultLanguageId, + onLanguageChange, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); const themesSettingsValuesRef = useRef({ @@ -1523,6 +1551,9 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba diff --git a/packages/ui/src/i18n/index.test.ts b/packages/ui/src/i18n/index.test.ts index dcae45ff..a89eebc6 100644 --- a/packages/ui/src/i18n/index.test.ts +++ b/packages/ui/src/i18n/index.test.ts @@ -199,4 +199,19 @@ describe('i18n instance', () => { expect(i18n.language).toBe('en'); expect(i18n.t('verseOfTheDay')).toBe(en.verseOfTheDay); }); + + it('applies an explicit locale over navigator language', async () => { + vi.stubGlobal('navigator', { + language: 'en-US', + languages: ['en-US', 'en'], + }); + vi.resetModules(); + + const i18n = await loadI18n(); + const { syncSdkLanguage } = await import('./index'); + await syncSdkLanguage('fr-FR'); + + expect(i18n.language).toBe('fr'); + expect(i18n.t('verseOfTheDay')).toBe(resources.fr.translation.verseOfTheDay); + }); }); diff --git a/packages/ui/src/i18n/index.ts b/packages/ui/src/i18n/index.ts index 24275595..b74dc409 100644 --- a/packages/ui/src/i18n/index.ts +++ b/packages/ui/src/i18n/index.ts @@ -12,16 +12,38 @@ const fallbackLng = 'en'; const i18n: I18nInstance = i18next.createInstance(); +/** + * Resolves a host-supplied or browser language tag to a bundled locale and + * applies it to the SDK i18n instance. + * + * Pass a BCP-47 tag (e.g. `es-MX`) when the host owns language — React Native + * Expo WebViews often report English in `navigator` even when the device is not. + * Omit the tag to follow the browser, matching {@link syncBrowserLanguageFromNavigator}. + * + * Call from YouVersionProvider — do not rely on module-load detection, which + * runs in Node during bundling/dep optimization and locks to fallbackLng. + */ +export function syncSdkLanguage(languageTag?: string): Promise { + let tags: readonly string[] | undefined; + if (languageTag === undefined) { + tags = getBrowserLanguages(); + } else { + tags = [languageTag]; + } + + const detected = resolveBrowserLanguage(tags, supportedLngs, fallbackLng); + if (i18n.language === detected) { + return Promise.resolve(detected); + } + return i18n.changeLanguage(detected).then(() => detected); +} + /** * Applies the user's browser language when running in a browser. - * Call from YouVersionProvider on mount — do not rely on module-load detection, - * which runs in Node during bundling/dep optimization and locks to fallbackLng. + * Call from YouVersionProvider on mount when no `locale` prop is set. */ export function syncBrowserLanguageFromNavigator(): void { - const detected = resolveBrowserLanguage(getBrowserLanguages(), supportedLngs, fallbackLng); - if (i18n.language !== detected) { - void i18n.changeLanguage(detected); - } + void syncSdkLanguage(); } function getInitialLanguage(): string { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7eaf5116..2bdf8366 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,4 +23,4 @@ export { type UseYVAuthReturn, } from '@youversion/platform-react-hooks'; -export { YouVersionProvider } from './components/YouVersionProvider'; +export { YouVersionProvider, type YouVersionProviderProps } from './components/YouVersionProvider'; From 38f823a960d5d9fc233102ae160cbc806af469be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 20:16:57 +0000 Subject: [PATCH 2/8] feat(demo): wire optional locale and Reader Bible language Let the Vite demo pass YouVersionProvider locale and BibleReader defaultLanguageId from env so hosts can exercise YPE-4813 locally. --- examples/vite-react/.env.example | 4 ++++ examples/vite-react/README.md | 1 + examples/vite-react/src/ThemedApp.tsx | 2 ++ examples/vite-react/src/pages/BibleReaderPage.tsx | 9 ++++++++- examples/vite-react/src/vite-env.d.ts | 13 +++++++++++++ 5 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 examples/vite-react/src/vite-env.d.ts diff --git a/examples/vite-react/.env.example b/examples/vite-react/.env.example index c2807a80..a35ea5ec 100644 --- a/examples/vite-react/.env.example +++ b/examples/vite-react/.env.example @@ -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" diff --git a/examples/vite-react/README.md b/examples/vite-react/README.md index 2ccbd3e5..b8aa3b11 100644 --- a/examples/vite-react/README.md +++ b/examples/vite-react/README.md @@ -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 ``` diff --git a/examples/vite-react/src/ThemedApp.tsx b/examples/vite-react/src/ThemedApp.tsx index eeefd473..937e6089 100644 --- a/examples/vite-react/src/ThemedApp.tsx +++ b/examples/vite-react/src/ThemedApp.tsx @@ -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 ( diff --git a/examples/vite-react/src/pages/BibleReaderPage.tsx b/examples/vite-react/src/pages/BibleReaderPage.tsx index 67bd8429..5cdf7aa1 100644 --- a/examples/vite-react/src/pages/BibleReaderPage.tsx +++ b/examples/vite-react/src/pages/BibleReaderPage.tsx @@ -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 (
- + diff --git a/examples/vite-react/src/vite-env.d.ts b/examples/vite-react/src/vite-env.d.ts new file mode 100644 index 00000000..41629bb8 --- /dev/null +++ b/examples/vite-react/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +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; +} From b795cdc3de37be0cd18905ba1e22e15e819b9a36 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 19 Aug 2026 15:47:28 -0500 Subject: [PATCH 3/8] test(ui): fold YPE-5119 host-locale coverage into the Reader locale PR Keep a single locale prop instead of a second lng API. Cover Verse of the Day copy and regional tags, apply language before paint, and document that app locale stays separate from Bible language. Co-authored-by: Cursor --- .changeset/ype-4813-reader-locale.md | 2 +- docs/i18n-guidelines.md | 14 ++++++++ packages/ui/README.md | 8 +++++ .../components/YouVersionProvider.test.tsx | 36 +++++++++++++++++++ .../ui/src/components/YouVersionProvider.tsx | 4 +-- packages/ui/src/i18n/index.test.ts | 5 +++ 6 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.changeset/ype-4813-reader-locale.md b/.changeset/ype-4813-reader-locale.md index 2f9f1fca..6a090970 100644 --- a/.changeset/ype-4813-reader-locale.md +++ b/.changeset/ype-4813-reader-locale.md @@ -2,4 +2,4 @@ '@youversion/platform-react-ui': minor --- -Hosts can pass `locale` on `YouVersionProvider` to set SDK UI language and `Accept-Language`, 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. +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. diff --git a/docs/i18n-guidelines.md b/docs/i18n-guidelines.md index 19330158..08f267bd 100644 --- a/docs/i18n-guidelines.md +++ b/docs/i18n-guidelines.md @@ -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 + + + +``` + +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 diff --git a/packages/ui/README.md b/packages/ui/README.md index 39af9d7e..d3259fae 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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 + + + +``` + ## Styling All component CSS is automatically injected when you wrap your app with `YouVersionProvider` — no extra imports or build steps needed. Under the hood, it uses React 19's [`