@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,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
|
+
}
|
package/src/lib/i18n/locale.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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
|
}
|
package/src/lib/i18n/server.ts
CHANGED
|
@@ -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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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>
|