@byline/i18n 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +166 -0
- package/dist/admin/en.js +424 -0
- package/dist/admin/fr.js +424 -0
- package/dist/admin/index.d.ts +44 -0
- package/dist/admin/index.js +27 -0
- package/dist/formatter.d.ts +40 -0
- package/dist/formatter.js +65 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +3 -0
- package/dist/merge.d.ts +39 -0
- package/dist/merge.js +58 -0
- package/dist/react/i18n-context.d.ts +29 -0
- package/dist/react/i18n-context.js +3 -0
- package/dist/react/i18n-provider.d.ts +31 -0
- package/dist/react/i18n-provider.js +38 -0
- package/dist/react/index.d.ts +24 -0
- package/dist/react/index.js +4 -0
- package/dist/react/language-menu.d.ts +15 -0
- package/dist/react/language-menu.js +78 -0
- package/dist/react/use-translation.d.ts +24 -0
- package/dist/react/use-translation.js +16 -0
- package/dist/resolve.d.ts +21 -0
- package/dist/resolve.js +28 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +1 -0
- package/package.json +100 -0
- package/src/admin/en.json +423 -0
- package/src/admin/fr.json +423 -0
- package/src/admin/index.test.node.ts +68 -0
- package/src/admin/index.ts +99 -0
- package/src/formatter.test.node.ts +163 -0
- package/src/formatter.ts +166 -0
- package/src/index.ts +47 -0
- package/src/merge.test.node.ts +85 -0
- package/src/merge.ts +135 -0
- package/src/react/i18n-context.ts +45 -0
- package/src/react/i18n-provider.tsx +76 -0
- package/src/react/index.ts +26 -0
- package/src/react/language-menu.tsx +115 -0
- package/src/react/use-translation.ts +45 -0
- package/src/resolve.test.node.ts +128 -0
- package/src/resolve.ts +84 -0
- package/src/types.ts +72 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type ReactNode, useMemo } from 'react'
|
|
10
|
+
|
|
11
|
+
import { createFormatter, type MissingTranslationEvent } from '../formatter.js'
|
|
12
|
+
import { I18nContext, type I18nContextValue } from './i18n-context.js'
|
|
13
|
+
import type { LocaleCode, LocaleDefinition, TranslationBundle } from '../types.js'
|
|
14
|
+
|
|
15
|
+
export interface I18nProviderProps {
|
|
16
|
+
bundle: TranslationBundle
|
|
17
|
+
activeLocale: LocaleCode
|
|
18
|
+
defaultLocale: LocaleCode
|
|
19
|
+
/** Permitted locale set with native names — drives `<LanguageMenu>`. */
|
|
20
|
+
localeDefinitions: readonly LocaleDefinition[]
|
|
21
|
+
/**
|
|
22
|
+
* Optional handler the host wires to its language-switcher server fn.
|
|
23
|
+
* Receives the new locale; expected to persist it and trigger a re-
|
|
24
|
+
* render with the updated `activeLocale` prop.
|
|
25
|
+
*/
|
|
26
|
+
setLocale?: (next: LocaleCode) => void | Promise<void>
|
|
27
|
+
/**
|
|
28
|
+
* Override the dev-time `console.warn` on missing translations.
|
|
29
|
+
* Defaults to a one-shot warn per `(locale, namespace, key)` triple
|
|
30
|
+
* when `process.env.NODE_ENV !== 'production'`.
|
|
31
|
+
*/
|
|
32
|
+
onMissing?: (event: MissingTranslationEvent) => void
|
|
33
|
+
children: ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function I18nProvider({
|
|
37
|
+
bundle,
|
|
38
|
+
activeLocale,
|
|
39
|
+
defaultLocale,
|
|
40
|
+
localeDefinitions,
|
|
41
|
+
setLocale,
|
|
42
|
+
onMissing,
|
|
43
|
+
children,
|
|
44
|
+
}: I18nProviderProps) {
|
|
45
|
+
const value = useMemo<I18nContextValue>(() => {
|
|
46
|
+
const onMissingResolved =
|
|
47
|
+
onMissing ?? (process.env.NODE_ENV !== 'production' ? defaultMissingWarner : undefined)
|
|
48
|
+
return {
|
|
49
|
+
formatter: createFormatter({
|
|
50
|
+
bundle,
|
|
51
|
+
activeLocale,
|
|
52
|
+
defaultLocale,
|
|
53
|
+
onMissing: onMissingResolved,
|
|
54
|
+
}),
|
|
55
|
+
bundle,
|
|
56
|
+
activeLocale,
|
|
57
|
+
defaultLocale,
|
|
58
|
+
localeDefinitions,
|
|
59
|
+
setLocale,
|
|
60
|
+
}
|
|
61
|
+
}, [bundle, activeLocale, defaultLocale, localeDefinitions, setLocale, onMissing])
|
|
62
|
+
|
|
63
|
+
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultMissingWarner(event: MissingTranslationEvent): void {
|
|
67
|
+
if (event.fellThroughToKey) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[@byline/i18n] missing translation: ${event.activeLocale}.${event.namespace}.${event.key} — also missing in default locale; rendered raw key.`
|
|
70
|
+
)
|
|
71
|
+
} else {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[@byline/i18n] missing translation: ${event.activeLocale}.${event.namespace}.${event.key} — using default-locale fallback.`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Single React-side barrel. Provider + hook + language switcher all
|
|
11
|
+
* share one React Context identity by virtue of living behind this
|
|
12
|
+
* single subpath export — splitting across more subpaths would risk
|
|
13
|
+
* Vite `optimizeDeps` pre-bundling each subpath into a private copy of
|
|
14
|
+
* the Context module, breaking provider/consumer identity (see the
|
|
15
|
+
* `@byline/ui/src/react.ts` comment for the same trap in another
|
|
16
|
+
* package).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { I18nContext } from './i18n-context.js'
|
|
20
|
+
export { I18nProvider } from './i18n-provider.js'
|
|
21
|
+
export { LanguageMenu } from './language-menu.js'
|
|
22
|
+
export { useTranslation } from './use-translation.js'
|
|
23
|
+
export type { I18nContextValue } from './i18n-context.js'
|
|
24
|
+
export type { I18nProviderProps } from './i18n-provider.js'
|
|
25
|
+
export type { LanguageMenuProps } from './language-menu.js'
|
|
26
|
+
export type { UseTranslationReturn } from './use-translation.js'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Self-contained language switcher. Reads `localeDefinitions`,
|
|
11
|
+
* `activeLocale`, and `setLocale` from `<I18nProvider>`'s context —
|
|
12
|
+
* the host wires the actual server fn (and admin-user update) behind
|
|
13
|
+
* `setLocale`; this component is pure UI.
|
|
14
|
+
*
|
|
15
|
+
* Renders nothing when fewer than two locales are configured, since
|
|
16
|
+
* the affordance has nothing to switch between.
|
|
17
|
+
*
|
|
18
|
+
* Host adapters typically mount this in the admin chrome top bar. It
|
|
19
|
+
* can also be embedded inside Account preferences (`<LanguageMenu />`
|
|
20
|
+
* with no className override) or anywhere else the menu makes sense.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useContext, useState } from 'react'
|
|
24
|
+
|
|
25
|
+
import { CheckIcon, Dropdown as DropdownMenu, GlobeIcon } from '@byline/ui/react'
|
|
26
|
+
import cx from 'classnames'
|
|
27
|
+
|
|
28
|
+
import { I18nContext } from './i18n-context.js'
|
|
29
|
+
import type { LocaleCode } from '../types.js'
|
|
30
|
+
|
|
31
|
+
export interface LanguageMenuProps {
|
|
32
|
+
className?: string
|
|
33
|
+
/** Tailwind / CSS class applied to the icon + label colour. */
|
|
34
|
+
color?: string
|
|
35
|
+
/** Render the menu disabled (loading, no `setLocale`, etc.). */
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function LanguageMenu({ className, color, disabled }: LanguageMenuProps) {
|
|
40
|
+
const context = useContext(I18nContext)
|
|
41
|
+
const [busy, setBusy] = useState(false)
|
|
42
|
+
|
|
43
|
+
if (context == null) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'[@byline/i18n] <LanguageMenu> must be used inside <I18nProvider>. Mount the provider in your admin shell root.'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { activeLocale, localeDefinitions, setLocale } = context
|
|
50
|
+
|
|
51
|
+
if (localeDefinitions.length < 2) return null
|
|
52
|
+
|
|
53
|
+
const active = localeDefinitions.find((d) => d.code === activeLocale)
|
|
54
|
+
const isDisabled = disabled || setLocale == null || busy
|
|
55
|
+
|
|
56
|
+
const handleSelect = async (next: LocaleCode) => {
|
|
57
|
+
if (next === activeLocale || setLocale == null || busy) return
|
|
58
|
+
setBusy(true)
|
|
59
|
+
try {
|
|
60
|
+
await setLocale(next)
|
|
61
|
+
} finally {
|
|
62
|
+
setBusy(false)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={className}>
|
|
68
|
+
<DropdownMenu.Root modal={false}>
|
|
69
|
+
<DropdownMenu.Trigger
|
|
70
|
+
render={
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label={active?.nativeName ?? activeLocale}
|
|
74
|
+
disabled={isDisabled}
|
|
75
|
+
className="component--byline-language-menu rounded flex items-center justify-between gap-1 outline-none disabled:opacity-50"
|
|
76
|
+
/>
|
|
77
|
+
}
|
|
78
|
+
>
|
|
79
|
+
<GlobeIcon svgClassName={color} />
|
|
80
|
+
<span className={cx(color, 'hidden sm:inline mr-[4px]')}>
|
|
81
|
+
{active?.nativeName ?? activeLocale}
|
|
82
|
+
</span>
|
|
83
|
+
</DropdownMenu.Trigger>
|
|
84
|
+
|
|
85
|
+
<DropdownMenu.Portal>
|
|
86
|
+
<DropdownMenu.Content
|
|
87
|
+
align="center"
|
|
88
|
+
sideOffset={10}
|
|
89
|
+
className={cx(
|
|
90
|
+
'z-40 rounded radix-side-bottom:animate-slide-down radix-side-top:animate-slide-up',
|
|
91
|
+
'w-32 px-1.5 py-1 shadow-md',
|
|
92
|
+
'bg-white dark:bg-canvas-800 border dark:border-canvas-700 shadow'
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{localeDefinitions.map((def) => {
|
|
96
|
+
const isActive = def.code === activeLocale
|
|
97
|
+
return (
|
|
98
|
+
<DropdownMenu.Item key={def.code} onClick={() => handleSelect(def.code)}>
|
|
99
|
+
<div className="flex">
|
|
100
|
+
<span className="inline-block w-[22px]">
|
|
101
|
+
{isActive && <CheckIcon width="18px" height="18px" />}
|
|
102
|
+
</span>
|
|
103
|
+
<span className="text-left inline-block w-full flex-1 self-start text-black dark:text-gray-300">
|
|
104
|
+
{def.nativeName}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
</DropdownMenu.Item>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</DropdownMenu.Content>
|
|
111
|
+
</DropdownMenu.Portal>
|
|
112
|
+
</DropdownMenu.Root>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useContext, useMemo } from 'react'
|
|
10
|
+
|
|
11
|
+
import { I18nContext } from './i18n-context.js'
|
|
12
|
+
import type { LocaleCode, MessageKey, Namespace, TranslationValues } from '../types.js'
|
|
13
|
+
|
|
14
|
+
export interface UseTranslationReturn {
|
|
15
|
+
/** Format `namespace.key` against the provider's active locale. */
|
|
16
|
+
t: (key: MessageKey, values?: TranslationValues) => string
|
|
17
|
+
/** The active locale — useful for `<html lang>` and direction logic. */
|
|
18
|
+
locale: LocaleCode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bind to a single namespace for the lifetime of the component. The
|
|
23
|
+
* returned `t` resolves keys against the active locale with default-
|
|
24
|
+
* locale fallback baked in (see `createFormatter` for the cascade).
|
|
25
|
+
*
|
|
26
|
+
* Throws if called outside `<I18nProvider>` — the loud failure is
|
|
27
|
+
* deliberate. A silently-broken admin shell with raw keys on screen
|
|
28
|
+
* is harder to notice than a thrown error during development.
|
|
29
|
+
*/
|
|
30
|
+
export function useTranslation(namespace: Namespace): UseTranslationReturn {
|
|
31
|
+
const context = useContext(I18nContext)
|
|
32
|
+
if (context == null) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'[@byline/i18n] useTranslation must be used inside <I18nProvider>. Mount the provider in your admin shell root.'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
const { formatter, activeLocale } = context
|
|
38
|
+
return useMemo(
|
|
39
|
+
() => ({
|
|
40
|
+
t: (key, values) => formatter.t(namespace, key, values),
|
|
41
|
+
locale: activeLocale,
|
|
42
|
+
}),
|
|
43
|
+
[formatter, namespace, activeLocale]
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { resolveInterfaceLocale } from './resolve.js'
|
|
12
|
+
|
|
13
|
+
const locales = ['en', 'fr', 'es']
|
|
14
|
+
const defaultLocale = 'en'
|
|
15
|
+
|
|
16
|
+
describe('resolveInterfaceLocale — tier 1 (preferred)', () => {
|
|
17
|
+
it('wins over every other signal when set and valid', () => {
|
|
18
|
+
const out = resolveInterfaceLocale({
|
|
19
|
+
locales,
|
|
20
|
+
defaultLocale,
|
|
21
|
+
preferred: 'fr',
|
|
22
|
+
cookie: 'es',
|
|
23
|
+
acceptLanguage: 'es;q=0.9, en;q=0.8',
|
|
24
|
+
})
|
|
25
|
+
expect(out).toBe('fr')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('falls through when preferred is not in the permitted set', () => {
|
|
29
|
+
const out = resolveInterfaceLocale({
|
|
30
|
+
locales,
|
|
31
|
+
defaultLocale,
|
|
32
|
+
preferred: 'de',
|
|
33
|
+
cookie: 'es',
|
|
34
|
+
})
|
|
35
|
+
expect(out).toBe('es')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls through when preferred is null', () => {
|
|
39
|
+
const out = resolveInterfaceLocale({
|
|
40
|
+
locales,
|
|
41
|
+
defaultLocale,
|
|
42
|
+
preferred: null,
|
|
43
|
+
cookie: 'fr',
|
|
44
|
+
})
|
|
45
|
+
expect(out).toBe('fr')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('resolveInterfaceLocale — tier 2 (cookie)', () => {
|
|
50
|
+
it('wins over Accept-Language', () => {
|
|
51
|
+
const out = resolveInterfaceLocale({
|
|
52
|
+
locales,
|
|
53
|
+
defaultLocale,
|
|
54
|
+
cookie: 'es',
|
|
55
|
+
acceptLanguage: 'fr;q=0.9',
|
|
56
|
+
})
|
|
57
|
+
expect(out).toBe('es')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('falls through when cookie points at a removed locale', () => {
|
|
61
|
+
const out = resolveInterfaceLocale({
|
|
62
|
+
locales,
|
|
63
|
+
defaultLocale,
|
|
64
|
+
cookie: 'de',
|
|
65
|
+
acceptLanguage: 'fr;q=0.9',
|
|
66
|
+
})
|
|
67
|
+
expect(out).toBe('fr')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls through when cookie is null', () => {
|
|
71
|
+
const out = resolveInterfaceLocale({
|
|
72
|
+
locales,
|
|
73
|
+
defaultLocale,
|
|
74
|
+
cookie: null,
|
|
75
|
+
acceptLanguage: 'fr;q=0.9',
|
|
76
|
+
})
|
|
77
|
+
expect(out).toBe('fr')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('resolveInterfaceLocale — tier 3 (Accept-Language)', () => {
|
|
82
|
+
it('picks the best match from the header', () => {
|
|
83
|
+
const out = resolveInterfaceLocale({
|
|
84
|
+
locales,
|
|
85
|
+
defaultLocale,
|
|
86
|
+
acceptLanguage: 'fr-CA;q=0.9, fr;q=0.8, en;q=0.5',
|
|
87
|
+
})
|
|
88
|
+
expect(out).toBe('fr')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('falls back to default when no header language is in the permitted set', () => {
|
|
92
|
+
const out = resolveInterfaceLocale({
|
|
93
|
+
locales,
|
|
94
|
+
defaultLocale,
|
|
95
|
+
acceptLanguage: 'de;q=0.9, ja;q=0.5',
|
|
96
|
+
})
|
|
97
|
+
expect(out).toBe(defaultLocale)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('handles malformed headers gracefully', () => {
|
|
101
|
+
const out = resolveInterfaceLocale({
|
|
102
|
+
locales,
|
|
103
|
+
defaultLocale,
|
|
104
|
+
acceptLanguage: 'this-is-not-a-valid-header',
|
|
105
|
+
})
|
|
106
|
+
// intl-localematcher returns the default when no match; this is the
|
|
107
|
+
// pure-fallback path.
|
|
108
|
+
expect(out).toBe(defaultLocale)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('resolveInterfaceLocale — tier 4 (default)', () => {
|
|
113
|
+
it('returns defaultLocale when every other tier produces nothing', () => {
|
|
114
|
+
const out = resolveInterfaceLocale({ locales, defaultLocale })
|
|
115
|
+
expect(out).toBe('en')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns defaultLocale when every signal is empty', () => {
|
|
119
|
+
const out = resolveInterfaceLocale({
|
|
120
|
+
locales,
|
|
121
|
+
defaultLocale,
|
|
122
|
+
preferred: null,
|
|
123
|
+
cookie: null,
|
|
124
|
+
acceptLanguage: null,
|
|
125
|
+
})
|
|
126
|
+
expect(out).toBe('en')
|
|
127
|
+
})
|
|
128
|
+
})
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pure locale-resolution cascade. Called once per request — server-side
|
|
11
|
+
* by the host adapter during request-context resolution; client-side
|
|
12
|
+
* during admin shell mount.
|
|
13
|
+
*
|
|
14
|
+
* Cascade (first non-null wins):
|
|
15
|
+
* 1. `preferred` — the authenticated admin user's stored preference
|
|
16
|
+
* (`admin_users.preferred_locale`). Always wins when set.
|
|
17
|
+
* 2. `cookie` — the `byline_admin_lng` cookie from the last language switch.
|
|
18
|
+
* 3. `acceptLanguage` — standards-compliant negotiation against the
|
|
19
|
+
* permitted locale set via `@formatjs/intl-localematcher`.
|
|
20
|
+
* 4. `defaultLocale` — last-resort fallback.
|
|
21
|
+
*
|
|
22
|
+
* Every step validates the candidate against the permitted `locales`
|
|
23
|
+
* set, so a stale cookie pointing at a removed locale falls through
|
|
24
|
+
* cleanly rather than producing a locale the bundle can't satisfy.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { match } from '@formatjs/intl-localematcher'
|
|
28
|
+
import Negotiator from 'negotiator'
|
|
29
|
+
|
|
30
|
+
import type { LocaleCode } from './types.js'
|
|
31
|
+
|
|
32
|
+
export interface ResolveInterfaceLocaleOptions {
|
|
33
|
+
/** Permitted locale set — from `i18n.interface.locales`. */
|
|
34
|
+
locales: readonly LocaleCode[]
|
|
35
|
+
/** Last-resort fallback — from `i18n.interface.defaultLocale`. */
|
|
36
|
+
defaultLocale: LocaleCode
|
|
37
|
+
/** `admin_users.preferred_locale` for the authenticated request, if any. */
|
|
38
|
+
preferred?: LocaleCode | null
|
|
39
|
+
/** Value of the `byline_admin_lng` cookie. */
|
|
40
|
+
cookie?: string | null
|
|
41
|
+
/** Raw `Accept-Language` request header. */
|
|
42
|
+
acceptLanguage?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveInterfaceLocale(options: ResolveInterfaceLocaleOptions): LocaleCode {
|
|
46
|
+
const { locales, defaultLocale, preferred, cookie, acceptLanguage } = options
|
|
47
|
+
|
|
48
|
+
// Tier 1 — admin user preference.
|
|
49
|
+
if (preferred != null && locales.includes(preferred)) {
|
|
50
|
+
return preferred
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Tier 2 — cookie.
|
|
54
|
+
if (cookie != null && locales.includes(cookie)) {
|
|
55
|
+
return cookie
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Tier 3 — Accept-Language negotiation.
|
|
59
|
+
if (acceptLanguage != null && acceptLanguage.length > 0) {
|
|
60
|
+
const matched = negotiateAcceptLanguage(acceptLanguage, locales, defaultLocale)
|
|
61
|
+
if (matched != null) return matched
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tier 4 — default.
|
|
65
|
+
return defaultLocale
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function negotiateAcceptLanguage(
|
|
69
|
+
header: string,
|
|
70
|
+
locales: readonly LocaleCode[],
|
|
71
|
+
defaultLocale: LocaleCode
|
|
72
|
+
): LocaleCode | null {
|
|
73
|
+
try {
|
|
74
|
+
const negotiator = new Negotiator({
|
|
75
|
+
headers: { 'accept-language': header },
|
|
76
|
+
})
|
|
77
|
+
const requested = negotiator.languages()
|
|
78
|
+
if (requested.length === 0) return null
|
|
79
|
+
const matched = match(requested, locales as LocaleCode[], defaultLocale)
|
|
80
|
+
return locales.includes(matched) ? matched : null
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Locale code — kept as a plain `string` so the package stays open to
|
|
11
|
+
* any BCP 47 tag (`en`, `pt-BR`, `zh-Hans-CN`). The host's
|
|
12
|
+
* `i18n.interface.locales` config is the canonical allow-list at
|
|
13
|
+
* runtime; the resolver / validator narrow against it.
|
|
14
|
+
*/
|
|
15
|
+
export type LocaleCode = string
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lightweight locale definition used by `<LanguageMenu>` to render the
|
|
19
|
+
* dropdown. The `code` matches a string in `i18n.interface.locales`;
|
|
20
|
+
* the `nativeName` is what the user sees ("English", "Español",
|
|
21
|
+
* "Français", "Deutsch", "日本語").
|
|
22
|
+
*/
|
|
23
|
+
export interface LocaleDefinition {
|
|
24
|
+
code: LocaleCode
|
|
25
|
+
nativeName: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Namespace string — convention is `byline-<package>` for Byline-shipped
|
|
30
|
+
* code and `<org>-<plugin>` for third-party plugins. The runtime treats
|
|
31
|
+
* namespaces as opaque keys.
|
|
32
|
+
*/
|
|
33
|
+
export type Namespace = string
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Message key inside a namespace — dot-segmented by convention
|
|
37
|
+
* (`chrome.sidebar.collapse`), but the runtime treats keys as opaque.
|
|
38
|
+
*/
|
|
39
|
+
export type MessageKey = string
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One namespace's translations for one locale — flat key → ICU
|
|
43
|
+
* MessageFormat-encoded string.
|
|
44
|
+
*/
|
|
45
|
+
export type NamespaceTranslations = Readonly<Record<MessageKey, string>>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The complete translation registry: locale → namespace → key →
|
|
49
|
+
* ICU-encoded message. Built by `mergeTranslations(...)`, passed to
|
|
50
|
+
* `<I18nProvider>`, and validated at boot by `@byline/core`.
|
|
51
|
+
*
|
|
52
|
+
* Bundles are deliberately plain JSON-shaped data — no functions, no
|
|
53
|
+
* React, no per-key metadata — so they can be authored as `.json`
|
|
54
|
+
* files, published as standalone npm packages, and round-tripped
|
|
55
|
+
* through any translation tool. Authoring-time metadata
|
|
56
|
+
* (descriptions, plural hints, deprecation markers) is a deferred
|
|
57
|
+
* Phase 4 surface; see `docs/I18N.md`.
|
|
58
|
+
*/
|
|
59
|
+
export type TranslationBundle = Readonly<{
|
|
60
|
+
[locale: LocaleCode]: Readonly<{
|
|
61
|
+
[namespace: Namespace]: NamespaceTranslations
|
|
62
|
+
}>
|
|
63
|
+
}>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Values argument to `t(key, values)`. `intl-messageformat` accepts
|
|
67
|
+
* strings, numbers, booleans, Dates, and React elements — but we narrow
|
|
68
|
+
* the public API to JSON-safe primitives + Date so the `t` return type
|
|
69
|
+
* stays `string`. Rich-element interpolation (e.g. inline links) goes
|
|
70
|
+
* through a separate `<Trans>` component (not part of the PR 1 surface).
|
|
71
|
+
*/
|
|
72
|
+
export type TranslationValues = Readonly<Record<string, string | number | boolean | Date | null>>
|