@open-mercato/shared 0.7.1-develop.7181.1.702cedc42c → 0.7.1-develop.7183.1.db9678eeb8
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/context.js +14 -3
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/i18n/locale-label.js +34 -0
- package/dist/lib/i18n/locale-label.js.map +7 -0
- package/dist/lib/i18n/locale-registry.js +73 -0
- package/dist/lib/i18n/locale-registry.js.map +7 -0
- package/dist/lib/i18n/locale-set.js +54 -0
- package/dist/lib/i18n/locale-set.js.map +7 -0
- package/dist/lib/i18n/locale.js +8 -8
- package/dist/lib/i18n/locale.js.map +2 -2
- package/dist/lib/i18n/server.js +22 -7
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/testing/renderWithProviders.js +2 -2
- package/dist/lib/testing/renderWithProviders.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-missing-key.ts +18 -0
- package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-ok.ts +28 -0
- package/src/lib/i18n/__tests__/__fixtures__/locale-unaugmented.ts +5 -0
- package/src/lib/i18n/__tests__/context-supported-locales.test.tsx +134 -0
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +109 -0
- package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +110 -0
- package/src/lib/i18n/__tests__/locale-augmentation.test.ts +56 -0
- package/src/lib/i18n/__tests__/locale-label.test.ts +141 -0
- package/src/lib/i18n/__tests__/locale-registry.test.ts +267 -0
- package/src/lib/i18n/config.ts +38 -1
- package/src/lib/i18n/config.typecheck.tsx +60 -0
- package/src/lib/i18n/context.tsx +30 -3
- package/src/lib/i18n/locale-label.ts +80 -0
- package/src/lib/i18n/locale-registry.ts +142 -0
- package/src/lib/i18n/locale-set.ts +98 -0
- package/src/lib/i18n/locale.ts +22 -6
- package/src/lib/i18n/server.ts +51 -7
- package/src/lib/testing/renderWithProviders.tsx +4 -2
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { defaultLocale, locales } from '../config'
|
|
2
|
+
import {
|
|
3
|
+
clearRegisteredLocales,
|
|
4
|
+
getRegisteredLocales,
|
|
5
|
+
getSupportedLocales,
|
|
6
|
+
isSupportedLocale,
|
|
7
|
+
registerLocales,
|
|
8
|
+
registerSupportedLocalesResolver,
|
|
9
|
+
resolveSupportedLocalesForRequest,
|
|
10
|
+
} from '../locale-registry'
|
|
11
|
+
import { resolveSupportedLocale, resolveLocaleFromAcceptLanguage } from '../locale'
|
|
12
|
+
|
|
13
|
+
describe('locale registry', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
clearRegisteredLocales()
|
|
16
|
+
registerSupportedLocalesResolver(null)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('with nothing registered (regression guard)', () => {
|
|
20
|
+
it('serves exactly the locales the platform ships, in order', () => {
|
|
21
|
+
expect(getSupportedLocales()).toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
22
|
+
expect(defaultLocale).toBe('en')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns the very same array instance as `locales`, so no copy can drift', () => {
|
|
26
|
+
expect(getSupportedLocales()).toBe(locales)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rejects a locale the platform does not ship', () => {
|
|
30
|
+
expect(isSupportedLocale('cs')).toBe(false)
|
|
31
|
+
expect(resolveSupportedLocale('cs')).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('registerLocales', () => {
|
|
36
|
+
it('adds a locale the platform does not ship', () => {
|
|
37
|
+
registerLocales(['cs'])
|
|
38
|
+
|
|
39
|
+
expect(isSupportedLocale('cs')).toBe(true)
|
|
40
|
+
expect(getSupportedLocales()).toEqual(['en', 'pl', 'es', 'de', 'ko', 'cs'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps the shipped locales first so existing ordering is untouched', () => {
|
|
44
|
+
registerLocales(['cs', 'fr'])
|
|
45
|
+
|
|
46
|
+
expect(getSupportedLocales().slice(0, 5)).toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('normalizes case, whitespace and underscores', () => {
|
|
50
|
+
registerLocales([' CS ', 'pt_BR'])
|
|
51
|
+
|
|
52
|
+
expect(getRegisteredLocales()).toEqual(['cs', 'pt-br'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('is idempotent', () => {
|
|
56
|
+
registerLocales(['cs'])
|
|
57
|
+
registerLocales(['cs'])
|
|
58
|
+
|
|
59
|
+
expect(getRegisteredLocales()).toEqual(['cs'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ignores locales the platform already ships', () => {
|
|
63
|
+
registerLocales(['en', 'pl'])
|
|
64
|
+
|
|
65
|
+
expect(getRegisteredLocales()).toEqual([])
|
|
66
|
+
expect(getSupportedLocales()).toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ignores a code that is not a language, rather than throwing', () => {
|
|
70
|
+
expect(() => registerLocales(['not-a-language', 'zzz', ''])).not.toThrow()
|
|
71
|
+
expect(getRegisteredLocales()).toEqual([])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('accepts a valid code even when a bad one is in the same batch', () => {
|
|
75
|
+
registerLocales(['zzz', 'cs'])
|
|
76
|
+
|
|
77
|
+
expect(getRegisteredLocales()).toEqual(['cs'])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('integration with locale resolution', () => {
|
|
82
|
+
it('makes a registered locale resolvable', () => {
|
|
83
|
+
expect(resolveSupportedLocale('cs')).toBeNull()
|
|
84
|
+
|
|
85
|
+
registerLocales(['cs'])
|
|
86
|
+
|
|
87
|
+
expect(resolveSupportedLocale('cs')).toBe('cs')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('folds a region subtag down to a registered base locale', () => {
|
|
91
|
+
registerLocales(['cs'])
|
|
92
|
+
|
|
93
|
+
expect(resolveSupportedLocale('cs-CZ')).toBe('cs')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('picks a registered locale out of an Accept-Language header', () => {
|
|
97
|
+
expect(resolveLocaleFromAcceptLanguage('cs-CZ,cs;q=0.9')).toBeNull()
|
|
98
|
+
|
|
99
|
+
registerLocales(['cs'])
|
|
100
|
+
|
|
101
|
+
expect(resolveLocaleFromAcceptLanguage('cs-CZ,cs;q=0.9')).toBe('cs')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('still honours q-value ordering once extra locales exist', () => {
|
|
105
|
+
registerLocales(['cs'])
|
|
106
|
+
|
|
107
|
+
expect(resolveLocaleFromAcceptLanguage('cs;q=0.5,de;q=0.9')).toBe('de')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('clearRegisteredLocales', () => {
|
|
112
|
+
it('restores the shipped set', () => {
|
|
113
|
+
registerLocales(['cs'])
|
|
114
|
+
clearRegisteredLocales()
|
|
115
|
+
|
|
116
|
+
expect(getSupportedLocales()).toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
117
|
+
expect(isSupportedLocale('cs')).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('resolveSupportedLocalesForRequest', () => {
|
|
122
|
+
it('serves the full set when no resolver is registered', async () => {
|
|
123
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('narrows the served set to the tenant selection', async () => {
|
|
127
|
+
registerSupportedLocalesResolver(async () => ['en', 'pl'])
|
|
128
|
+
|
|
129
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('preserves the platform ordering rather than the tenant ordering', async () => {
|
|
133
|
+
registerSupportedLocalesResolver(async () => ['pl', 'en'])
|
|
134
|
+
|
|
135
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('serves a tenant-selected locale that the app registered', async () => {
|
|
139
|
+
registerLocales(['cs'])
|
|
140
|
+
registerSupportedLocalesResolver(async () => ['en', 'cs'])
|
|
141
|
+
|
|
142
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'cs'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('drops a configured locale that has no dictionary source behind it', async () => {
|
|
146
|
+
registerSupportedLocalesResolver(async () => ['en', 'cs'])
|
|
147
|
+
|
|
148
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en'])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('falls back to the full set when the selection matches nothing servable', async () => {
|
|
152
|
+
registerSupportedLocalesResolver(async () => ['cs', 'fr'])
|
|
153
|
+
|
|
154
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('treats "no stored selection" as no opinion', async () => {
|
|
158
|
+
registerSupportedLocalesResolver(async () => null)
|
|
159
|
+
|
|
160
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('treats an empty selection as no opinion', async () => {
|
|
164
|
+
registerSupportedLocalesResolver(async () => [])
|
|
165
|
+
|
|
166
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('normalizes the configured codes before matching', async () => {
|
|
170
|
+
registerSupportedLocalesResolver(async () => ['EN', ' pl '])
|
|
171
|
+
|
|
172
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl'])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('never throws when the resolver fails — the root layout depends on it', async () => {
|
|
176
|
+
registerSupportedLocalesResolver(async () => {
|
|
177
|
+
throw new Error('database unavailable')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('keeping the default locale servable', () => {
|
|
184
|
+
// `detectLocale` falls back to `defaultLocale` whenever neither the cookie
|
|
185
|
+
// nor Accept-Language matches. If the served set could exclude it, the page
|
|
186
|
+
// would render a language its own switcher does not list.
|
|
187
|
+
it('keeps the default locale in a selection that omits it', async () => {
|
|
188
|
+
registerSupportedLocalesResolver(async () => ['pl', 'de'])
|
|
189
|
+
|
|
190
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'de'])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('keeps the platform ordering when it adds the default back', async () => {
|
|
194
|
+
registerSupportedLocalesResolver(async () => ['ko', 'de'])
|
|
195
|
+
|
|
196
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'de', 'ko'])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('does not duplicate the default locale when the selection includes it', async () => {
|
|
200
|
+
registerSupportedLocalesResolver(async () => ['en', 'pl'])
|
|
201
|
+
|
|
202
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not resurrect the default from a selection that matches nothing servable', async () => {
|
|
206
|
+
// An entirely unservable selection is a typo, not an opinion: the full
|
|
207
|
+
// set is the right answer, not a one-entry set containing only `en`.
|
|
208
|
+
registerSupportedLocalesResolver(async () => ['cs', 'fr'])
|
|
209
|
+
|
|
210
|
+
await expect(resolveSupportedLocalesForRequest()).resolves.toEqual(['en', 'pl', 'es', 'de', 'ko'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('always contains a locale `detectLocale` is allowed to return', async () => {
|
|
214
|
+
registerLocales(['cs'])
|
|
215
|
+
registerSupportedLocalesResolver(async () => ['pl', 'cs'])
|
|
216
|
+
|
|
217
|
+
const served = await resolveSupportedLocalesForRequest()
|
|
218
|
+
|
|
219
|
+
expect(served).toContain(defaultLocale)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('array identity', () => {
|
|
225
|
+
// `useSupportedLocales()` hands this array straight to callers, so a fresh
|
|
226
|
+
// identity on every call re-fires any `useEffect`/`useMemo` depending on it.
|
|
227
|
+
it('is stable across calls once a locale is registered', () => {
|
|
228
|
+
registerLocales(['cs'])
|
|
229
|
+
|
|
230
|
+
expect(getSupportedLocales()).toBe(getSupportedLocales())
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('changes identity when the set actually changes', () => {
|
|
234
|
+
const before = getSupportedLocales()
|
|
235
|
+
registerLocales(['cs'])
|
|
236
|
+
|
|
237
|
+
expect(getSupportedLocales()).not.toBe(before)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('returns to the `locales` instance after clearing', () => {
|
|
241
|
+
registerLocales(['cs'])
|
|
242
|
+
clearRegisteredLocales()
|
|
243
|
+
|
|
244
|
+
expect(getSupportedLocales()).toBe(locales)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('isSupportedLocale normalization', () => {
|
|
249
|
+
// `registerLocales` normalizes what it stores, so the membership test has to
|
|
250
|
+
// normalize what it is asked about or the two disagree.
|
|
251
|
+
it('accepts a shipped locale regardless of case and padding', () => {
|
|
252
|
+
expect(isSupportedLocale('EN')).toBe(true)
|
|
253
|
+
expect(isSupportedLocale(' pl ')).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('accepts the canonical BCP-47 form of a registered region locale', () => {
|
|
257
|
+
registerLocales(['pt_BR'])
|
|
258
|
+
|
|
259
|
+
expect(isSupportedLocale('pt-BR')).toBe(true)
|
|
260
|
+
expect(isSupportedLocale('pt-br')).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('still rejects a locale nobody registered', () => {
|
|
264
|
+
expect(isSupportedLocale('CS')).toBe(false)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
})
|
package/src/lib/i18n/config.ts
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The set of languages the platform ships dictionaries for.
|
|
3
|
+
*
|
|
4
|
+
* `Locale` is derived from `LocaleRegistry` rather than written as a closed
|
|
5
|
+
* union so that a downstream application can serve a language the platform does
|
|
6
|
+
* not ship, without patching or forking `@open-mercato/shared`. Augment the
|
|
7
|
+
* interface from the app and the new code becomes a valid `Locale` everywhere:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* declare module '@open-mercato/shared/lib/i18n/config' {
|
|
11
|
+
* interface LocaleRegistry { cs: true }
|
|
12
|
+
* }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Unaugmented, `Locale` resolves to exactly `'en' | 'pl' | 'es' | 'de' | 'ko'`,
|
|
16
|
+
* so existing exhaustive `Record<Locale, T>` maps keep their drift-guard value —
|
|
17
|
+
* and an app that opts in keeps exhaustiveness over its own extended set. This
|
|
18
|
+
* is the same `keyof SomeRegistry` + declaration-merging idiom TypeScript uses
|
|
19
|
+
* on itself (`NumberFormatOptionsStyleRegistry` in `lib.es5.d.ts`, additively
|
|
20
|
+
* merged with `unit` in `lib.es2020.intl.d.ts`).
|
|
21
|
+
*
|
|
22
|
+
* The type layer is advisory only: declaration merging applies when a package is
|
|
23
|
+
* *installed*, not when it is *enabled*, so it can claim a locale the running app
|
|
24
|
+
* never registered. `getSupportedLocales()` in `./locale-registry` is the single
|
|
25
|
+
* runtime authority, and every entry point validates against it.
|
|
26
|
+
*/
|
|
27
|
+
export interface LocaleRegistry {
|
|
28
|
+
en: true
|
|
29
|
+
pl: true
|
|
30
|
+
es: true
|
|
31
|
+
de: true
|
|
32
|
+
ko: true
|
|
33
|
+
}
|
|
2
34
|
|
|
35
|
+
export type Locale = keyof LocaleRegistry & string
|
|
36
|
+
|
|
37
|
+
// NOTE: `scripts/dev.mjs` reads the next two declarations by regex (it parses
|
|
38
|
+
// this file as text to build the dev splash screen before the app compiles).
|
|
39
|
+
// Keep them as literal `export const <name>: <Type> = <literal>` statements.
|
|
3
40
|
export const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
|
|
4
41
|
export const defaultLocale: Locale = 'en'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Compile-time-only guard for the shape of `Locale`. Never imported at runtime:
|
|
2
|
+
// `yarn typecheck` (`tsc --noEmit`) is the only gate that can catch a regression
|
|
3
|
+
// here, because this repo's Jest transform runs with `isolatedModules: true` and
|
|
4
|
+
// therefore skips type diagnostics.
|
|
5
|
+
//
|
|
6
|
+
// It sits outside `__tests__` because `packages/shared/tsconfig.json` excludes
|
|
7
|
+
// that directory from `tsc --noEmit`, and it is a `.tsx` rather than a `.ts`
|
|
8
|
+
// because the mutation gate mutates changed `src/lib/**/*.ts` files and runs only
|
|
9
|
+
// their related Jest tests. See the identical reasoning in `context.typecheck.tsx`.
|
|
10
|
+
//
|
|
11
|
+
// What it protects: `Locale` is derived from `LocaleRegistry` so downstream apps
|
|
12
|
+
// can widen it by declaration merging. That indirection must not accidentally
|
|
13
|
+
// degrade it to `string`, which would silently delete every exhaustiveness check
|
|
14
|
+
// the platform and its apps rely on.
|
|
15
|
+
import type { Locale, LocaleRegistry } from './config'
|
|
16
|
+
|
|
17
|
+
// 1. Unaugmented, `Locale` is still the exact five-member union.
|
|
18
|
+
type Expected = 'en' | 'pl' | 'es' | 'de' | 'ko'
|
|
19
|
+
type MutuallyAssignable<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
|
|
20
|
+
const localeUnionIsUnchanged: MutuallyAssignable<Locale, Expected> = true
|
|
21
|
+
|
|
22
|
+
// 2. It has NOT collapsed to `string` — that would make the union check above
|
|
23
|
+
// pass vacuously in one direction and destroy exhaustiveness everywhere.
|
|
24
|
+
const localeIsNotWidenedToString: MutuallyAssignable<Locale, string> = false
|
|
25
|
+
|
|
26
|
+
// 3. Every shipped code is assignable.
|
|
27
|
+
const shipped: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
|
|
28
|
+
|
|
29
|
+
// 4. A code nobody registered is still rejected.
|
|
30
|
+
// @ts-expect-error 'cs' is not a member of Locale until an app augments LocaleRegistry
|
|
31
|
+
const unregistered: Locale = 'cs'
|
|
32
|
+
|
|
33
|
+
// 5. Exhaustive `Record<Locale, T>` still works as a drift guard: omitting a
|
|
34
|
+
// member must remain an error, which is what apps depend on today.
|
|
35
|
+
const exhaustiveLabels: Record<Locale, string> = {
|
|
36
|
+
en: 'English',
|
|
37
|
+
pl: 'Polski',
|
|
38
|
+
es: 'Español',
|
|
39
|
+
de: 'Deutsch',
|
|
40
|
+
ko: '한국어',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// @ts-expect-error a Record<Locale, …> missing `ko` must stay an error
|
|
44
|
+
const nonExhaustiveLabels: Record<Locale, string> = {
|
|
45
|
+
en: 'English',
|
|
46
|
+
pl: 'Polski',
|
|
47
|
+
es: 'Español',
|
|
48
|
+
de: 'Deutsch',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 6. The registry keys and the union stay in lockstep.
|
|
52
|
+
const registryKeysMatchLocale: MutuallyAssignable<keyof LocaleRegistry & string, Locale> = true
|
|
53
|
+
|
|
54
|
+
void localeUnionIsUnchanged
|
|
55
|
+
void localeIsNotWidenedToString
|
|
56
|
+
void shipped
|
|
57
|
+
void unregistered
|
|
58
|
+
void exhaustiveLabels
|
|
59
|
+
void nonExhaustiveLabels
|
|
60
|
+
void registryKeysMatchLocale
|
package/src/lib/i18n/context.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import { createContext, useContext, useMemo, type PropsWithChildren } from 'react'
|
|
3
3
|
import type { Locale } from './config'
|
|
4
|
+
import { getSupportedLocales } from './locale-set'
|
|
4
5
|
|
|
5
6
|
export type Dict = Record<string, string>
|
|
6
7
|
|
|
@@ -17,6 +18,12 @@ export type I18nContextValue = {
|
|
|
17
18
|
t: TranslateFn
|
|
18
19
|
/** True when the locale is pinned via `OM_FORCE_LOCALE`; UI should hide switchers. */
|
|
19
20
|
localeLocked: boolean
|
|
21
|
+
/**
|
|
22
|
+
* Every locale this app serves. Resolved on the server (where the app
|
|
23
|
+
* registry and any tenant configuration are readable) and handed to the
|
|
24
|
+
* client, because a client bundle cannot see either.
|
|
25
|
+
*/
|
|
26
|
+
supportedLocales: readonly Locale[]
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
const I18N_CONTEXT_KEY = '__openMercatoI18nContext'
|
|
@@ -48,10 +55,20 @@ function format(template: string, params?: TranslateParams) {
|
|
|
48
55
|
})
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
export function I18nProvider({ children, locale, dict, localeLocked
|
|
58
|
+
export function I18nProvider({ children, locale, dict, localeLocked, supportedLocales }: PropsWithChildren<{ locale: Locale; dict: Dict; localeLocked?: boolean; supportedLocales?: readonly Locale[] }>) {
|
|
59
|
+
// A nested provider (the backend layout mounts one inside the root layout's)
|
|
60
|
+
// shadows the whole subtree, so a prop it does not pass would otherwise be
|
|
61
|
+
// silently downgraded to the registry default for every consumer below it.
|
|
62
|
+
// Inheriting from the enclosing provider first makes an omitted prop mean
|
|
63
|
+
// "unchanged" rather than "reset", which is what a nested mount intends.
|
|
64
|
+
const outer = useContext(I18nContext)
|
|
52
65
|
const value = useMemo<I18nContextValue>(() => ({
|
|
53
66
|
locale,
|
|
54
|
-
localeLocked,
|
|
67
|
+
localeLocked: localeLocked ?? outer?.localeLocked ?? false,
|
|
68
|
+
// Falls back to the process-local registry so a provider mounted without the
|
|
69
|
+
// prop and without an enclosing one (tests, standalone renders) behaves
|
|
70
|
+
// exactly as it did before.
|
|
71
|
+
supportedLocales: supportedLocales ?? outer?.supportedLocales ?? getSupportedLocales(),
|
|
55
72
|
t: (key, fallbackOrParams, params) => {
|
|
56
73
|
let fallback: string | undefined
|
|
57
74
|
let resolvedParams: TranslateParams | undefined
|
|
@@ -66,7 +83,7 @@ export function I18nProvider({ children, locale, dict, localeLocked = false }: P
|
|
|
66
83
|
const template = dict[key] ?? fallback ?? key
|
|
67
84
|
return format(template, resolvedParams)
|
|
68
85
|
},
|
|
69
|
-
}), [locale, dict, localeLocked])
|
|
86
|
+
}), [locale, dict, localeLocked, supportedLocales, outer])
|
|
70
87
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
71
88
|
}
|
|
72
89
|
|
|
@@ -103,6 +120,16 @@ export function useOptionalLocale(): Locale | undefined {
|
|
|
103
120
|
return ctx?.locale
|
|
104
121
|
}
|
|
105
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every locale this app serves, for rendering a language picker. Falls back to
|
|
125
|
+
* the process-local registry outside a provider so callers can render
|
|
126
|
+
* unconditionally.
|
|
127
|
+
*/
|
|
128
|
+
export function useSupportedLocales(): readonly Locale[] {
|
|
129
|
+
const ctx = useContext(I18nContext)
|
|
130
|
+
return ctx?.supportedLocales ?? getSupportedLocales()
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
/**
|
|
107
134
|
* True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`
|
|
108
135
|
* when no provider is in scope so callers can render unconditionally.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { TranslateFn } from './context'
|
|
2
|
+
|
|
3
|
+
type ShippedLocaleLabel = {
|
|
4
|
+
/** Dictionary key, so the label itself is localized when a translator is given. */
|
|
5
|
+
key: string
|
|
6
|
+
/** Endonym — the language's name in its own language. */
|
|
7
|
+
native: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The locales the platform ships. Kept as literal data rather than derived from
|
|
11
|
+
// `Intl.DisplayNames` because `Intl` disagrees on casing for some of them
|
|
12
|
+
// (`español`, `polski`), and these strings are already user-visible.
|
|
13
|
+
const SHIPPED_LOCALE_LABELS: Record<string, ShippedLocaleLabel> = {
|
|
14
|
+
en: { key: 'common.languages.english', native: 'English' },
|
|
15
|
+
pl: { key: 'common.languages.polish', native: 'Polski' },
|
|
16
|
+
es: { key: 'common.languages.spanish', native: 'Español' },
|
|
17
|
+
de: { key: 'common.languages.german', native: 'Deutsch' },
|
|
18
|
+
ko: { key: 'common.languages.korean', native: '한국어' },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// `resolveLocaleLabel` is called once per option per render of every language
|
|
22
|
+
// switcher, and constructing an `Intl.DisplayNames` is not free. The answer for
|
|
23
|
+
// a given code never changes within a process.
|
|
24
|
+
const intlDisplayNames = new Map<string, string | undefined>()
|
|
25
|
+
|
|
26
|
+
function resolveIntlDisplayName(locale: string): string | undefined {
|
|
27
|
+
if (intlDisplayNames.has(locale)) return intlDisplayNames.get(locale)
|
|
28
|
+
const resolved = computeIntlDisplayName(locale)
|
|
29
|
+
intlDisplayNames.set(locale, resolved)
|
|
30
|
+
return resolved
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function computeIntlDisplayName(locale: string): string | undefined {
|
|
34
|
+
try {
|
|
35
|
+
// Ask for the language's name in its own language, so a switcher reads the
|
|
36
|
+
// way a speaker of that language expects it to.
|
|
37
|
+
const displayName = new Intl.DisplayNames([locale], { type: 'language' }).of(locale)
|
|
38
|
+
// `Intl` echoes the input back when it has no data for the code.
|
|
39
|
+
if (!displayName || displayName.toLowerCase() === locale.toLowerCase()) return undefined
|
|
40
|
+
return displayName
|
|
41
|
+
} catch {
|
|
42
|
+
// Invalid or unsupported code — fall through to the uppercased code.
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A human-readable name for any locale code, including ones the platform does
|
|
49
|
+
* not ship dictionaries for.
|
|
50
|
+
*
|
|
51
|
+
* Resolution order:
|
|
52
|
+
* 1. the shipped table — via `t` when given, so the label is itself localized
|
|
53
|
+
* (a German UI shows "Polnisch"); otherwise the endonym ("Polski")
|
|
54
|
+
* 2. `Intl.DisplayNames` — the endonym for an arbitrary code, no dependency
|
|
55
|
+
* 3. the uppercased code — never blank
|
|
56
|
+
*
|
|
57
|
+
* Deliberately does **not** consult `./iso639`. Every caller of this function is
|
|
58
|
+
* a client component — the admin `ProfileDropdown`, the storefront
|
|
59
|
+
* `LanguageSwitcher`, the public checkout pay page — and `iso639.ts` is a
|
|
60
|
+
* 186-entry table with a module-scope `Set` no bundler can tree-shake, so
|
|
61
|
+
* importing it here would ship 7 KB of language catalogue to a conversion-
|
|
62
|
+
* critical public route in order to render five labels that rung 1 already
|
|
63
|
+
* answered. `Intl.DisplayNames` names essentially any code an app would plausibly
|
|
64
|
+
* register, in every browser this app supports, so the catalogue was only ever a
|
|
65
|
+
* fallback for a fallback. A code `Intl` cannot name degrades to its uppercased
|
|
66
|
+
* form, which is never blank. Server and admin callers that genuinely need the
|
|
67
|
+
* catalogue keep importing `getIso639Label` from `./iso639` directly.
|
|
68
|
+
*
|
|
69
|
+
* Pass `t` where the surrounding UI renders localized language names, and omit
|
|
70
|
+
* it where it renders endonyms. Both conventions exist in the codebase and the
|
|
71
|
+
* caller decides which one it wants.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveLocaleLabel(locale: string, t?: TranslateFn): string {
|
|
74
|
+
const shipped = SHIPPED_LOCALE_LABELS[locale]
|
|
75
|
+
if (shipped) {
|
|
76
|
+
return t ? t(shipped.key, shipped.native) : shipped.native
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return resolveIntlDisplayName(locale) ?? locale.toUpperCase()
|
|
80
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { defaultLocale, locales, type Locale } from './config'
|
|
2
|
+
import { invalidateDictionaryCache } from './dictionary-cache'
|
|
3
|
+
import { isValidIso639 } from './iso639'
|
|
4
|
+
import { createLogger } from '../logger'
|
|
5
|
+
import {
|
|
6
|
+
addRegisteredLocale,
|
|
7
|
+
clearRegisteredLocaleSet,
|
|
8
|
+
getSupportedLocales,
|
|
9
|
+
normalizeLocaleCode,
|
|
10
|
+
} from './locale-set'
|
|
11
|
+
|
|
12
|
+
// Constructed lazily rather than at module scope: a module-level factory call is
|
|
13
|
+
// a side effect no bundler can drop, which would pin this whole module — and the
|
|
14
|
+
// logger facade behind it — into any bundle that merely imports one of its
|
|
15
|
+
// tree-shakeable exports.
|
|
16
|
+
let cachedLogger: ReturnType<typeof createLogger> | null = null
|
|
17
|
+
function logger() {
|
|
18
|
+
if (!cachedLogger) cachedLogger = createLogger('shared').child({ component: 'i18n-locale-registry' })
|
|
19
|
+
return cachedLogger
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The read side lives in `./locale-set`, which has no dependencies beyond
|
|
23
|
+
// `./config` so client bundles can import it without pulling in the logger, the
|
|
24
|
+
// ISO 639 table or the dictionary cache. Re-exported here so `locale-registry`
|
|
25
|
+
// remains the one import path callers need to know about.
|
|
26
|
+
export {
|
|
27
|
+
getSupportedLocales,
|
|
28
|
+
isSupportedLocale,
|
|
29
|
+
getRegisteredLocales,
|
|
30
|
+
normalizeLocaleCode,
|
|
31
|
+
} from './locale-set'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register additional locales this application serves on top of the ones the
|
|
35
|
+
* platform ships in `locales`.
|
|
36
|
+
*
|
|
37
|
+
* Runtime half of the extension point; the compile-time half is augmenting
|
|
38
|
+
* `LocaleRegistry` in `./config`. Both are needed for an app-defined locale to
|
|
39
|
+
* be usable, and this one is the authority — the type layer cannot be trusted to
|
|
40
|
+
* reflect what the running app actually registered.
|
|
41
|
+
*
|
|
42
|
+
* Codes are normalized (`pt_BR` → `pt-br`) and validated against ISO 639-1;
|
|
43
|
+
* unknown codes are ignored with a warning rather than thrown, so one bad entry
|
|
44
|
+
* in app config cannot take the app down at boot. Registering is idempotent, and
|
|
45
|
+
* re-registering the shipped locales is a no-op.
|
|
46
|
+
*/
|
|
47
|
+
export function registerLocales(codes: readonly string[]): void {
|
|
48
|
+
let added = false
|
|
49
|
+
|
|
50
|
+
for (const code of codes) {
|
|
51
|
+
const normalized = normalizeLocaleCode(code)
|
|
52
|
+
if (!normalized) continue
|
|
53
|
+
if ((locales as readonly string[]).includes(normalized)) continue
|
|
54
|
+
// Region subtags (`pt-br`) are normalized but validated on their base code,
|
|
55
|
+
// matching how `resolveSupportedLocale` folds a region down to its language.
|
|
56
|
+
if (!isValidIso639(normalized.split('-')[0] ?? normalized)) {
|
|
57
|
+
logger().warn('Ignoring unknown locale code', { code })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (addRegisteredLocale(normalized)) added = true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The dictionary a locale resolves to is derived from the supported set, so a
|
|
64
|
+
// widened set invalidates everything built from the narrower one.
|
|
65
|
+
if (added) invalidateDictionaryCache()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Drop every app-registered locale. Intended for tests. */
|
|
69
|
+
export function clearRegisteredLocales(): void {
|
|
70
|
+
if (clearRegisteredLocaleSet()) invalidateDictionaryCache()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves the locale codes the current tenant has opted into, or `null` when
|
|
75
|
+
* there is no tenant context or no stored selection.
|
|
76
|
+
*/
|
|
77
|
+
export type SupportedLocalesResolver = () => Promise<readonly string[] | null>
|
|
78
|
+
|
|
79
|
+
const RESOLVER_GLOBAL_KEY = '__openMercatoI18nSupportedLocalesResolver__'
|
|
80
|
+
|
|
81
|
+
type ResolverGlobalScope = typeof globalThis & {
|
|
82
|
+
[RESOLVER_GLOBAL_KEY]?: SupportedLocalesResolver | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register the source of per-tenant locale configuration.
|
|
87
|
+
*
|
|
88
|
+
* `@open-mercato/shared` cannot read tenant configuration itself — that needs a
|
|
89
|
+
* DI container and a domain module — so the owning module registers a resolver
|
|
90
|
+
* here, the same way `registerTranslationOverlayPlugin` inverts the dependency
|
|
91
|
+
* for content translations. With nothing registered the served set is exactly
|
|
92
|
+
* the process-local registry, which is today's behaviour.
|
|
93
|
+
*
|
|
94
|
+
* There is one slot: a second registration replaces the first. That is what
|
|
95
|
+
* makes an enterprise overlay able to take over the tenant lookup, so it is not
|
|
96
|
+
* an error, but it is warned about — silently losing the `translations` module's
|
|
97
|
+
* resolver to an accidental second call is otherwise undiagnosable.
|
|
98
|
+
*/
|
|
99
|
+
export function registerSupportedLocalesResolver(resolver: SupportedLocalesResolver | null): void {
|
|
100
|
+
const scope = globalThis as ResolverGlobalScope
|
|
101
|
+
if (resolver && scope[RESOLVER_GLOBAL_KEY]) {
|
|
102
|
+
logger().warn('Replacing an already-registered supported-locales resolver')
|
|
103
|
+
}
|
|
104
|
+
scope[RESOLVER_GLOBAL_KEY] = resolver
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The locales to offer for the current request: the tenant's selection narrowed
|
|
109
|
+
* to those the app can actually serve.
|
|
110
|
+
*
|
|
111
|
+
* Intersecting rather than replacing means a code that was configured but has no
|
|
112
|
+
* dictionary source behind it can never reach a language switcher, so a typo in
|
|
113
|
+
* the settings screen cannot strand a tenant in a broken UI. An empty
|
|
114
|
+
* intersection falls back to the full set for the same reason. Never throws —
|
|
115
|
+
* this runs in the root layout, where a failure would take down every page.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveSupportedLocalesForRequest(): Promise<readonly Locale[]> {
|
|
118
|
+
const available = getSupportedLocales()
|
|
119
|
+
const resolver = (globalThis as ResolverGlobalScope)[RESOLVER_GLOBAL_KEY]
|
|
120
|
+
if (!resolver) return available
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const configured = await resolver()
|
|
124
|
+
if (!configured || configured.length === 0) return available
|
|
125
|
+
|
|
126
|
+
const selected = new Set(configured.map(normalizeLocaleCode))
|
|
127
|
+
if (!available.some((locale) => selected.has(locale))) return available
|
|
128
|
+
|
|
129
|
+
// `detectLocale` falls back to `defaultLocale` whenever neither the cookie
|
|
130
|
+
// nor Accept-Language matches, so the default has to stay servable. Without
|
|
131
|
+
// this, a tenant selecting only `['pl','de']` renders an English page whose
|
|
132
|
+
// own switcher does not list English: a blank Select trigger, no checked row
|
|
133
|
+
// in the profile menu, and no way for the user to get back.
|
|
134
|
+
selected.add(defaultLocale)
|
|
135
|
+
// Filtering `available` rather than mapping the selection preserves the
|
|
136
|
+
// platform's locale ordering instead of the tenant's.
|
|
137
|
+
return available.filter((locale) => selected.has(locale))
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger().warn('Failed to resolve tenant supported locales; serving the full set', { err })
|
|
140
|
+
return available
|
|
141
|
+
}
|
|
142
|
+
}
|