@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,98 @@
1
+ import { locales, type Locale } from './config'
2
+
3
+ // The read side of the locale registry, kept in its own module with no imports
4
+ // beyond `./config` so it is safe — and cheap — to pull into a client bundle.
5
+ // `context.tsx` is a "use client" module and needs `getSupportedLocales()`; if it
6
+ // reached for `./locale-registry` instead it would drag the logger facade, the
7
+ // dictionary cache and the ISO 639 table into every route that mounts
8
+ // `I18nProvider`, because `./locale-registry` has module-level side effects that
9
+ // no bundler can tree-shake away.
10
+ //
11
+ // Registration pattern for publishable packages.
12
+ // Use globalThis to survive tsx/esbuild module duplication where the same file
13
+ // can be loaded as multiple module instances when mixing dynamic and static
14
+ // imports. The registry and the dictionary cache it invalidates must stay
15
+ // coherent across those instances. Mirrors `../modules/registry.ts` and
16
+ // `./dictionary-cache.ts`.
17
+ const GLOBAL_KEY = '__openMercatoI18nLocaleRegistry__'
18
+ const CACHE_KEY = '__openMercatoI18nSupportedLocales__'
19
+
20
+ type LocaleSetGlobalScope = typeof globalThis & {
21
+ [GLOBAL_KEY]?: Set<string>
22
+ [CACHE_KEY]?: readonly Locale[] | null
23
+ }
24
+
25
+ function globalScope(): LocaleSetGlobalScope {
26
+ return globalThis as LocaleSetGlobalScope
27
+ }
28
+
29
+ function getRegistered(): Set<string> {
30
+ const scope = globalScope()
31
+ if (!scope[GLOBAL_KEY]) {
32
+ scope[GLOBAL_KEY] = new Set<string>()
33
+ }
34
+ return scope[GLOBAL_KEY]
35
+ }
36
+
37
+ /** `pt_BR` → `pt-br`. Applied on both sides of every comparison. */
38
+ export function normalizeLocaleCode(value: string): string {
39
+ return value.trim().toLowerCase().replace(/_/g, '-')
40
+ }
41
+
42
+ /**
43
+ * Every locale this application can serve: the platform baseline plus anything
44
+ * registered by the app. The single runtime authority on the locale set — prefer
45
+ * it over importing `locales` directly anywhere a user-supplied value is being
46
+ * validated or a locale list is being rendered.
47
+ *
48
+ * Memoized so the returned array keeps a stable identity between mutations.
49
+ * `useSupportedLocales()` hands this straight to callers, and a fresh array on
50
+ * every render would re-fire any `useEffect`/`useMemo` that depends on it.
51
+ */
52
+ export function getSupportedLocales(): readonly Locale[] {
53
+ const scope = globalScope()
54
+ const cached = scope[CACHE_KEY]
55
+ if (cached) return cached
56
+
57
+ const registered = getRegistered()
58
+ // With nothing registered this is `locales` itself, not a copy, so the common
59
+ // case allocates nothing and callers can compare by identity.
60
+ const resolved = registered.size === 0 ? locales : ([...locales, ...registered] as Locale[])
61
+ scope[CACHE_KEY] = resolved
62
+ return resolved
63
+ }
64
+
65
+ /** True when `code` is a locale this application serves. */
66
+ export function isSupportedLocale(code: string): boolean {
67
+ return (getSupportedLocales() as readonly string[]).includes(normalizeLocaleCode(code))
68
+ }
69
+
70
+ /** Locales registered by the app, excluding the platform baseline. Test seam. */
71
+ export function getRegisteredLocales(): readonly string[] {
72
+ return [...getRegistered()]
73
+ }
74
+
75
+ /**
76
+ * Add one already-normalized, already-validated code. Returns whether the set
77
+ * actually changed, so the caller knows when to invalidate what it derived.
78
+ * Internal to `./locale-registry` — applications call `registerLocales`.
79
+ */
80
+ export function addRegisteredLocale(normalized: string): boolean {
81
+ const registered = getRegistered()
82
+ if (registered.has(normalized)) return false
83
+ registered.add(normalized)
84
+ globalScope()[CACHE_KEY] = null
85
+ return true
86
+ }
87
+
88
+ /**
89
+ * Drop every app-registered locale. Returns whether the set actually changed.
90
+ * Internal to `./locale-registry` — tests call `clearRegisteredLocales`.
91
+ */
92
+ export function clearRegisteredLocaleSet(): boolean {
93
+ const registered = getRegistered()
94
+ if (registered.size === 0) return false
95
+ registered.clear()
96
+ globalScope()[CACHE_KEY] = null
97
+ return true
98
+ }
@@ -1,21 +1,35 @@
1
- import { locales, type Locale } from './config'
1
+ import type { Locale } from './config'
2
+ import { getSupportedLocales } from './locale-set'
2
3
 
3
4
  function normalizeLocaleToken(value: string): string {
4
5
  return value.trim().toLowerCase().replace(/_/g, '-')
5
6
  }
6
7
 
7
- export function resolveSupportedLocale(value: string | null | undefined): Locale | null {
8
+ /**
9
+ * Canonicalize a user-supplied locale token against the set of locales that may
10
+ * be served, folding a region subtag down to its base language (`de-AT` → `de`).
11
+ *
12
+ * `supported` defaults to the process-wide set. Pass the request's served set —
13
+ * from `resolveSupportedLocalesForRequest()` — anywhere the answer is written
14
+ * somewhere durable, such as the `locale` cookie: the process-wide set is wider
15
+ * than a tenant's selection, so validating against it would accept a locale that
16
+ * every later render then discards, and report success while nothing changes.
17
+ */
18
+ export function resolveSupportedLocale(
19
+ value: string | null | undefined,
20
+ supported: readonly Locale[] = getSupportedLocales(),
21
+ ): Locale | null {
8
22
  if (typeof value !== 'string') return null
9
23
 
10
24
  const normalized = normalizeLocaleToken(value)
11
25
  if (!normalized) return null
12
26
 
13
- if (locales.includes(normalized as Locale)) {
27
+ if (supported.includes(normalized as Locale)) {
14
28
  return normalized as Locale
15
29
  }
16
30
 
17
31
  const baseLocale = normalized.split('-')[0]
18
- if (baseLocale && locales.includes(baseLocale as Locale)) {
32
+ if (baseLocale && supported.includes(baseLocale as Locale)) {
19
33
  return baseLocale as Locale
20
34
  }
21
35
 
@@ -24,9 +38,10 @@ export function resolveSupportedLocale(value: string | null | undefined): Locale
24
38
 
25
39
  export function resolveLocaleFromCandidates(
26
40
  candidates: Iterable<string | null | undefined>,
41
+ supported?: readonly Locale[],
27
42
  ): Locale | null {
28
43
  for (const candidate of candidates) {
29
- const resolved = resolveSupportedLocale(candidate)
44
+ const resolved = resolveSupportedLocale(candidate, supported)
30
45
  if (resolved) return resolved
31
46
  }
32
47
  return null
@@ -46,6 +61,7 @@ export function resolveForcedLocale(
46
61
 
47
62
  export function resolveLocaleFromAcceptLanguage(
48
63
  acceptLanguage: string | null | undefined,
64
+ supported?: readonly Locale[],
49
65
  ): Locale | null {
50
66
  if (typeof acceptLanguage !== 'string' || acceptLanguage.trim().length === 0) {
51
67
  return null
@@ -70,5 +86,5 @@ export function resolveLocaleFromAcceptLanguage(
70
86
  return left.index - right.index
71
87
  })
72
88
 
73
- return resolveLocaleFromCandidates(rankedCandidates.map((entry) => entry.locale))
89
+ return resolveLocaleFromCandidates(rankedCandidates.map((entry) => entry.locale), supported)
74
90
  }
@@ -5,11 +5,18 @@ import { createFallbackTranslator, createTranslator } from './translate'
5
5
  import { tryGetModules } from '../modules/registry'
6
6
  import { loadAppDictionary } from './app-dictionaries'
7
7
  import { getCachedDictionary, setCachedDictionary } from './dictionary-cache'
8
+ import { getSupportedLocales, resolveSupportedLocalesForRequest } from './locale-registry'
8
9
 
9
10
  // Re-export for backwards compatibility
10
11
  export { registerModules, getModules } from '../modules/registry'
11
12
  export { registerAppDictionaryLoader } from './app-dictionaries'
12
13
  export { invalidateDictionaryCache } from './dictionary-cache'
14
+ export {
15
+ registerLocales,
16
+ getSupportedLocales,
17
+ registerSupportedLocalesResolver,
18
+ resolveSupportedLocalesForRequest,
19
+ } from './locale-registry'
13
20
 
14
21
  function flattenDictionary(source: unknown, prefix = ''): Dict {
15
22
  if (!source || typeof source !== 'object' || Array.isArray(source)) return {}
@@ -26,22 +33,35 @@ function flattenDictionary(source: unknown, prefix = ''): Dict {
26
33
  return result
27
34
  }
28
35
 
29
- export async function detectLocale(): Promise<Locale> {
36
+ export type DetectLocaleOptions = {
37
+ /**
38
+ * Restrict detection to this set — typically the current tenant's selection,
39
+ * resolved by the caller via `resolveSupportedLocalesForRequest()`. Omitted,
40
+ * detection uses the process-wide supported set, which is the prior behaviour.
41
+ */
42
+ supportedLocales?: readonly Locale[]
43
+ }
44
+
45
+ export async function detectLocale(options?: DetectLocaleOptions): Promise<Locale> {
30
46
  // Ops-level override: pin the whole app to one locale (default: unset).
31
47
  const forced = resolveForcedLocale(process.env)
32
48
  if (forced) return forced
49
+ const supported = options?.supportedLocales ?? getSupportedLocales()
33
50
  // Dynamic import to avoid requiring Next.js in non-Next.js contexts (CLI, tests)
34
51
  try {
35
52
  const { cookies, headers } = await import('next/headers')
36
53
  try {
37
54
  const c = (await cookies()).get('locale')?.value
38
- if (c && locales.includes(c as Locale)) return c as Locale
55
+ if (c && supported.includes(c as Locale)) return c as Locale
39
56
  } catch {
40
57
  // cookies() may not be available outside request context (e.g., in tests)
41
58
  }
42
59
  try {
43
60
  const accept = (await headers()).get('accept-language') || ''
44
- const match = resolveLocaleFromAcceptLanguage(accept)
61
+ // Matched against the served set rather than the process-wide one, so a
62
+ // header like `de, en` on a tenant that serves only `en` picks `en`
63
+ // instead of matching `de` first and then discarding the whole header.
64
+ const match = resolveLocaleFromAcceptLanguage(accept, supported)
45
65
  if (match) return match
46
66
  } catch {
47
67
  // headers() may not be available outside request context (e.g., in tests)
@@ -49,7 +69,14 @@ export async function detectLocale(): Promise<Locale> {
49
69
  } catch {
50
70
  // next/headers not available (CLI context)
51
71
  }
52
- return defaultLocale
72
+ // The caller may have narrowed the set past the default locale, and returning
73
+ // a locale outside the served set would render a page whose own language
74
+ // switcher does not offer the language it is written in.
75
+ // `resolveSupportedLocalesForRequest` keeps `defaultLocale` in the set for
76
+ // exactly this reason; the `supported[0]` arm covers a caller that narrowed by
77
+ // hand and did not.
78
+ if (supported.includes(defaultLocale)) return defaultLocale
79
+ return supported[0] ?? defaultLocale
53
80
  }
54
81
 
55
82
  export async function loadDictionary(locale: Locale): Promise<Dict> {
@@ -58,9 +85,18 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
58
85
  // modules or the app dictionary loader are (re)registered.
59
86
  const cached = getCachedDictionary(locale)
60
87
  if (cached) return cached
88
+ // A locale the platform does not ship has no dictionaries of its own yet, and
89
+ // roughly a quarter of `t()` call sites pass no inline fallback — without a
90
+ // base layer those render as raw keys. Layering the default locale underneath
91
+ // makes an app- or operator-added locale degrade to English instead, which is
92
+ // what every comparable platform does. Shipped locales skip this entirely and
93
+ // keep their previous merge semantics byte for byte.
94
+ const needsDefaultLocaleBase =
95
+ locale !== defaultLocale && !(locales as readonly string[]).includes(locale)
96
+ const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}
61
97
  // Load from registry instead of @/ import (works in standalone packages)
62
98
  const baseRaw = await loadAppDictionary(locale)
63
- const merged: Dict = { ...flattenDictionary(baseRaw) }
99
+ Object.assign(merged, flattenDictionary(baseRaw))
64
100
  // Route handlers translate their responses, so they resolve a dictionary even
65
101
  // when they are exercised in isolation without a bootstrapped registry. The
66
102
  // app dictionary alone is the right degraded answer there — `registerModules`
@@ -74,8 +110,16 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
74
110
  return merged
75
111
  }
76
112
 
77
- export async function resolveTranslations() {
78
- const locale = await detectLocale()
113
+ /**
114
+ * Detect the locale and load its dictionary in one step.
115
+ *
116
+ * `options` is forwarded to `detectLocale`, so a caller that has already
117
+ * resolved the request's served set (a layout mounting its own `I18nProvider`)
118
+ * detects against that set instead of the process-wide one. Omitted — which is
119
+ * every route handler — behaviour is unchanged and no tenant lookup is made.
120
+ */
121
+ export async function resolveTranslations(options?: DetectLocaleOptions) {
122
+ const locale = await detectLocale(options)
79
123
  const dict = await loadDictionary(locale)
80
124
  const t = createTranslator(dict)
81
125
  const translate = createFallbackTranslator(dict)
@@ -8,19 +8,21 @@ type ProviderOptions = {
8
8
  locale?: string
9
9
  dict?: Record<string, unknown>
10
10
  queryClient?: QueryClient
11
+ /** Narrow the served locale set, as the server does for a tenant selection. */
12
+ supportedLocales?: readonly string[]
11
13
  }
12
14
 
13
15
  export function renderWithProviders(
14
16
  ui: React.ReactElement,
15
17
  options?: RenderOptions & ProviderOptions,
16
18
  ) {
17
- const { locale = 'en', dict = {}, queryClient = new QueryClient(), ...rest } = options ?? {}
19
+ const { locale = 'en', dict = {}, queryClient = new QueryClient(), supportedLocales, ...rest } = options ?? {}
18
20
 
19
21
  function Wrapper({ children }: { children: React.ReactNode }) {
20
22
  return (
21
23
  <QueryClientProvider client={queryClient}>
22
24
  {/* @ts-expect-error shared provider accepts loose dict shape */}
23
- <I18nProvider locale={locale} dict={dict}>
25
+ <I18nProvider locale={locale} dict={dict} supportedLocales={supportedLocales}>
24
26
  {children}
25
27
  </I18nProvider>
26
28
  </QueryClientProvider>