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
8 changes: 5 additions & 3 deletions src/targets/public/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<I18n lang={lang} dictRequire={lang => require(`@/locales/${lang}`)}>
Expand All @@ -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()

Expand All @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions src/targets/public/localeHelper.js
Original file line number Diff line number Diff line change
@@ -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'
97 changes: 97 additions & 0 deletions src/targets/public/localeHelper.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading