@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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/i18n/config.js.map +2 -2
  3. package/dist/lib/i18n/context.js +14 -3
  4. package/dist/lib/i18n/context.js.map +2 -2
  5. package/dist/lib/i18n/locale-label.js +34 -0
  6. package/dist/lib/i18n/locale-label.js.map +7 -0
  7. package/dist/lib/i18n/locale-registry.js +73 -0
  8. package/dist/lib/i18n/locale-registry.js.map +7 -0
  9. package/dist/lib/i18n/locale-set.js +54 -0
  10. package/dist/lib/i18n/locale-set.js.map +7 -0
  11. package/dist/lib/i18n/locale.js +8 -8
  12. package/dist/lib/i18n/locale.js.map +2 -2
  13. package/dist/lib/i18n/server.js +22 -7
  14. package/dist/lib/i18n/server.js.map +3 -3
  15. package/dist/lib/testing/renderWithProviders.js +2 -2
  16. package/dist/lib/testing/renderWithProviders.js.map +2 -2
  17. package/dist/lib/version.js +1 -1
  18. package/dist/lib/version.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-missing-key.ts +18 -0
  21. package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-ok.ts +28 -0
  22. package/src/lib/i18n/__tests__/__fixtures__/locale-unaugmented.ts +5 -0
  23. package/src/lib/i18n/__tests__/context-supported-locales.test.tsx +134 -0
  24. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +109 -0
  25. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +110 -0
  26. package/src/lib/i18n/__tests__/locale-augmentation.test.ts +56 -0
  27. package/src/lib/i18n/__tests__/locale-label.test.ts +141 -0
  28. package/src/lib/i18n/__tests__/locale-registry.test.ts +267 -0
  29. package/src/lib/i18n/config.ts +38 -1
  30. package/src/lib/i18n/config.typecheck.tsx +60 -0
  31. package/src/lib/i18n/context.tsx +30 -3
  32. package/src/lib/i18n/locale-label.ts +80 -0
  33. package/src/lib/i18n/locale-registry.ts +142 -0
  34. package/src/lib/i18n/locale-set.ts +98 -0
  35. package/src/lib/i18n/locale.ts +22 -6
  36. package/src/lib/i18n/server.ts +51 -7
  37. 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
+ })
@@ -1,4 +1,41 @@
1
- export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'
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
@@ -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 = false }: PropsWithChildren<{ locale: Locale; dict: Dict; localeLocked?: boolean }>) {
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
+ }