diff --git a/.changeset/ype-4813-reader-locale.md b/.changeset/ype-4813-reader-locale.md new file mode 100644 index 00000000..6a090970 --- /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` (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/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; +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 8764d116..43c67395 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 + + + +``` + ### 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. diff --git a/packages/ui/src/components/YouVersionProvider.test.tsx b/packages/ui/src/components/YouVersionProvider.test.tsx index 3ff187aa..24a5d956 100644 --- a/packages/ui/src/components/YouVersionProvider.test.tsx +++ b/packages/ui/src/components/YouVersionProvider.test.tsx @@ -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; @@ -36,6 +38,50 @@ describe('UI YouVersionProvider', () => { expect(screen.getByTestId('headers').textContent).toBe('none'); }); + it('sends Accept-Language from locale', () => { + render( + + + , + ); + + expect(screen.getByTestId('headers').textContent).toBe( + JSON.stringify({ 'Accept-Language': 'es-MX' }), + ); + }); + + it('lets additionalHeaders override Accept-Language from locale', () => { + render( + + + , + ); + + 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( + + + , + ); + + 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; @@ -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( + +
+ , + ); + + expect(i18n.language).toBe('es'); + + rerender( + +
+ , + ); + + 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( + +
+ , + ); + + expect(i18n.language).toBe('es'); + + await i18n.changeLanguage('en'); + }); }); diff --git a/packages/ui/src/components/YouVersionProvider.tsx b/packages/ui/src/components/YouVersionProvider.tsx index 85bd1548..17922bbc 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 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'; @@ -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, -): React.ReactElement { - useEffect(() => { - syncBrowserLanguageFromNavigator(); - }, []); +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; + + // 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); + } + + useLayoutEffect(() => { + 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 @@ -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 ( - + {/* 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 8b71ac6a..119c0ffe 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -71,6 +71,28 @@ function renderWithOverrides(ui: ReactElement) { return render({ui}); } +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', @@ -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( + + + + + , + ); + + expect(requestedLanguages.includes('es')).toBe(true); + }); + + it('uses a controlled languageId for the version picker', () => { + const { overrides, requestedLanguages } = overridesRecordingVersionLanguage(); + + render( + + + + + , + ); + + expect(requestedLanguages.includes('ko')).toBe(true); + }); +}); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 470e5f84..3daf30c3 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/components/use-bible-reader-highlights.dom-vapor.test.tsx b/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx index 16b3991a..398c543c 100644 --- a/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx +++ b/packages/ui/src/components/use-bible-reader-highlights.dom-vapor.test.tsx @@ -95,14 +95,21 @@ describe('vapor flash — real Verse.Html DOM paint (MutationObserver on style)' ); const verseEl = () => container.querySelector('.yv-v[v="2"]'); - // Wait until server truth has painted verse 2 yellow. + // Wait until server truth has painted verse 2 yellow and the remove + // callback is installed. Verse.Html paints from the hook in useLayoutEffect; + // HIGHLIGHTS_UPDATED reaches the machine in a later useEffect. Calling + // remove before that send lands is a no-op (empty serverColors → no write). await waitFor( () => { const bg = verseEl()?.style.backgroundColor ?? ''; expect(bg).not.toBe(''); + expect(removeRef.current).toEqual(expect.any(Function)); }, { timeout: 5000 }, ); + await act(async () => { + await Promise.resolve(); + }); const mountFetches = getHighlights.mock.calls.length; // Instrument: record every background-color the verse-2 wrapper takes on @@ -114,9 +121,13 @@ describe('vapor flash — real Verse.Html DOM paint (MutationObserver on style)' }); observer.observe(el, { attributes: true, attributeFilter: ['style'] }); + const remove = removeRef.current; + if (!remove) { + throw new Error('remove callback was not installed'); + } act(() => { removed = true; - removeRef.current?.(); + remove(); }); // These two waits are synchronization gates, not the assertions under test diff --git a/packages/ui/src/i18n/index.test.ts b/packages/ui/src/i18n/index.test.ts index db9ccf1a..3ee29cf6 100644 --- a/packages/ui/src/i18n/index.test.ts +++ b/packages/ui/src/i18n/index.test.ts @@ -234,4 +234,24 @@ 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); + + await syncSdkLanguage('es-MX'); + + expect(i18n.language).toBe('es'); + expect(i18n.t('verseOfTheDay')).toBe(resources.es.translation.verseOfTheDay); + }); }); diff --git a/packages/ui/src/i18n/index.ts b/packages/ui/src/i18n/index.ts index 1a556799..fde8d35e 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';