diff --git a/src/targets/public/index.jsx b/src/targets/public/index.jsx index 2c506eef02..4ce7075de3 100644 --- a/src/targets/public/index.jsx +++ b/src/targets/public/index.jsx @@ -33,6 +33,8 @@ import registerClientPlugins from '@/lib/registerClientPlugins' import configureStore from '@/store/configureStore' import styles from '@/styles/main.styl' +import { getPublicPageLocale } from './localeHelper' + const renderError = (lang, root) => createRoot(root).render( require(`@/locales/${lang}`)}> @@ -45,9 +47,9 @@ const renderError = (lang, root) => ) const init = async () => { - const lang = document.documentElement.getAttribute('lang') || 'en' const root = document.querySelector('[role=application]') const dataset = JSON.parse(root.dataset.cozy) + const lang = getPublicPageLocale(dataset) const { sharecode, isOnlyOfficeDocShared, onlyOfficeDocId, username } = getQueryParameter() @@ -67,8 +69,8 @@ const init = async () => { Document.registerClient(client) } - const polyglot = initTranslation(dataset.locale, lang => - require(`@/locales/${lang}`) + const polyglot = initTranslation(lang, locale => + require(`@/locales/${locale}`) ) const store = configureStore({ diff --git a/src/targets/public/localeHelper.js b/src/targets/public/localeHelper.js new file mode 100644 index 0000000000..0401dc8272 --- /dev/null +++ b/src/targets/public/localeHelper.js @@ -0,0 +1,48 @@ +import { locales } from '@/locales' + +const supportedLocales = new Set(Object.keys(locales)) + +/** + * Returns the best matching supported locale from the browser's language + * preferences. Iterates through `navigator.languages` (or `navigator.language` + * as a fallback), normalizing BCP 47 tags (e.g. `zh-CN` → `zh_CN`) and + * trying the primary subtag (e.g. `fr` from `fr-CA`) before moving on. + * + * @returns {string} A supported locale key (e.g. `'fr'`, `'zh_CN'`), or `'en'` + * if no browser language matches. + */ +export const getBrowserLocale = () => { + const languages = navigator.languages ?? [navigator.language || 'en'] + + for (const language of languages) { + // BCP 47 uses hyphens (zh-CN) but our locale keys use underscores (zh_CN) + const normalized = language.replaceAll('-', '_') + if (supportedLocales.has(normalized)) { + return normalized + } + const primary = normalized.split('_')[0] + if (supportedLocales.has(primary)) { + return primary + } + } + + return 'en' +} + +/** + * Determines the locale for the public sharing page. + * + * When `isLoggedIn` is `false`, the visitor is anonymous so the browser locale + * is used. When `isLoggedIn` is `true` or absent (cozy-stack < PR#4719), the + * instance locale from the dataset is used for backward compatibility. + * + * @param {object} dataset - The parsed `data-cozy` dataset from the DOM root. + * @param {boolean} [dataset.isLoggedIn] - Whether the current user is + * authenticated. Absent on older cozy-stack versions. + * @param {string} [dataset.locale] - The Cozy instance locale (e.g. `'fr'`). + * @returns {string} The resolved locale key to use for translations. + */ +export const getPublicPageLocale = dataset => + 'isLoggedIn' in dataset && !dataset.isLoggedIn + ? getBrowserLocale() + : dataset.locale || 'en' diff --git a/src/targets/public/localeHelper.spec.js b/src/targets/public/localeHelper.spec.js new file mode 100644 index 0000000000..5d0c7a8330 --- /dev/null +++ b/src/targets/public/localeHelper.spec.js @@ -0,0 +1,97 @@ +import { getBrowserLocale, getPublicPageLocale } from './localeHelper' + +describe('getBrowserLocale', () => { + const originalNavigator = { ...navigator } + + const mockLanguages = (languages, language) => { + Object.defineProperty(navigator, 'languages', { + value: languages, + configurable: true + }) + Object.defineProperty(navigator, 'language', { + value: language ?? (languages?.[0] || 'en'), + configurable: true + }) + } + + afterEach(() => { + Object.defineProperty(navigator, 'languages', { + value: originalNavigator.languages, + configurable: true + }) + Object.defineProperty(navigator, 'language', { + value: originalNavigator.language, + configurable: true + }) + }) + + it('returns an exact match', () => { + mockLanguages(['fr']) + expect(getBrowserLocale()).toBe('fr') + }) + + it('normalizes BCP 47 hyphens to underscores', () => { + mockLanguages(['zh-CN']) + expect(getBrowserLocale()).toBe('zh_CN') + }) + + it('falls back to the primary subtag when the full tag is unsupported', () => { + mockLanguages(['fr-CA']) + expect(getBrowserLocale()).toBe('fr') + }) + + it('returns the first supported language from the preference list', () => { + mockLanguages(['sv', 'de', 'fr']) + expect(getBrowserLocale()).toBe('de') + }) + + it('falls back to en when no language matches', () => { + mockLanguages(['xx', 'yy']) + expect(getBrowserLocale()).toBe('en') + }) + + it('uses navigator.language when navigator.languages is undefined', () => { + mockLanguages(undefined, 'ja') + expect(getBrowserLocale()).toBe('ja') + }) + + it('falls back to en when both navigator.languages and navigator.language are undefined', () => { + mockLanguages(undefined, undefined) + expect(getBrowserLocale()).toBe('en') + }) +}) + +describe('getPublicPageLocale', () => { + const originalNavigator = { ...navigator } + + const mockLanguages = languages => { + Object.defineProperty(navigator, 'languages', { + value: languages, + configurable: true + }) + } + + afterEach(() => { + Object.defineProperty(navigator, 'languages', { + value: originalNavigator.languages, + configurable: true + }) + }) + + it('uses browser locale when isLoggedIn is false', () => { + mockLanguages(['es']) + expect(getPublicPageLocale({ isLoggedIn: false, locale: 'fr' })).toBe('es') + }) + + it('uses instance locale when isLoggedIn is true', () => { + expect(getPublicPageLocale({ isLoggedIn: true, locale: 'fr' })).toBe('fr') + }) + + it('uses instance locale when isLoggedIn is absent (backward compat)', () => { + expect(getPublicPageLocale({ locale: 'de' })).toBe('de') + }) + + it('falls back to en when isLoggedIn is absent and locale is missing', () => { + expect(getPublicPageLocale({})).toBe('en') + }) +})