@open-mercato/shared 0.6.6-develop.6377.1.d26fed7324 → 0.6.6-develop.6381.1.042df302c3
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/dist/lib/i18n/context.js +8 -2
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/i18n/locale.js +4 -0
- package/dist/lib/i18n/locale.js.map +2 -2
- package/dist/lib/i18n/server.js +3 -1
- package/dist/lib/i18n/server.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__/forced-locale.test.ts +25 -0
- package/src/lib/i18n/context.tsx +14 -2
- package/src/lib/i18n/locale.ts +12 -0
- package/src/lib/i18n/server.ts +4 -1
package/dist/lib/i18n/context.js
CHANGED
|
@@ -22,9 +22,10 @@ function format(template, params) {
|
|
|
22
22
|
return String(value);
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
-
function I18nProvider({ children, locale, dict }) {
|
|
25
|
+
function I18nProvider({ children, locale, dict, localeLocked = false }) {
|
|
26
26
|
const value = useMemo(() => ({
|
|
27
27
|
locale,
|
|
28
|
+
localeLocked,
|
|
28
29
|
t: (key, fallbackOrParams, params) => {
|
|
29
30
|
let fallback;
|
|
30
31
|
let resolvedParams;
|
|
@@ -37,7 +38,7 @@ function I18nProvider({ children, locale, dict }) {
|
|
|
37
38
|
const template = dict[key] ?? fallback ?? key;
|
|
38
39
|
return format(template, resolvedParams);
|
|
39
40
|
}
|
|
40
|
-
}), [locale, dict]);
|
|
41
|
+
}), [locale, dict, localeLocked]);
|
|
41
42
|
return /* @__PURE__ */ jsx(I18nContext.Provider, { value, children });
|
|
42
43
|
}
|
|
43
44
|
function useT() {
|
|
@@ -54,9 +55,14 @@ function useLocale() {
|
|
|
54
55
|
if (!ctx) throw new Error("useLocale must be used within I18nProvider");
|
|
55
56
|
return ctx.locale;
|
|
56
57
|
}
|
|
58
|
+
function useLocaleLocked() {
|
|
59
|
+
const ctx = useContext(I18nContext);
|
|
60
|
+
return ctx?.localeLocked ?? false;
|
|
61
|
+
}
|
|
57
62
|
export {
|
|
58
63
|
I18nProvider,
|
|
59
64
|
useLocale,
|
|
65
|
+
useLocaleLocked,
|
|
60
66
|
useOptionalT,
|
|
61
67
|
useT
|
|
62
68
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/i18n/context.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { createContext, useContext, useMemo, type ReactNode } from 'react'\nimport type { Locale } from './config'\n\nexport type Dict = Record<string, string>\n\nexport type TranslateParams = Record<string, string | number>\n\nexport type TranslateFn = (\n key: string,\n fallbackOrParams?: string | TranslateParams,\n params?: TranslateParams\n) => string\n\nexport type I18nContextValue = {\n locale: Locale\n t: TranslateFn\n}\n\nconst I18N_CONTEXT_KEY = '__openMercatoI18nContext'\n\ntype GlobalI18nContextStore = typeof globalThis & {\n [I18N_CONTEXT_KEY]?: ReturnType<typeof createContext<I18nContextValue | null>>\n}\n\nfunction getI18nContext() {\n const store = globalThis as GlobalI18nContextStore\n if (!store[I18N_CONTEXT_KEY]) {\n store[I18N_CONTEXT_KEY] = createContext<I18nContextValue | null>(null)\n }\n return store[I18N_CONTEXT_KEY]\n}\n\nconst I18nContext = getI18nContext()\n\nfunction format(template: string, params?: TranslateParams) {\n if (!params) return template\n return template.replace(/\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g, (_, doubleKey, singleKey) => {\n const key = doubleKey ?? singleKey\n if (!key) return _\n const value = params[key]\n if (value === undefined) {\n return doubleKey ? `{{${key}}}` : `{${key}}`\n }\n return String(value)\n })\n}\n\nexport function I18nProvider({ children, locale, dict }: { children: ReactNode; locale: Locale; dict: Dict }) {\n const value = useMemo<I18nContextValue>(() => ({\n locale,\n t: (key, fallbackOrParams, params) => {\n let fallback: string | undefined\n let resolvedParams: TranslateParams | undefined\n\n if (typeof fallbackOrParams === 'string') {\n fallback = fallbackOrParams\n resolvedParams = params\n } else {\n resolvedParams = fallbackOrParams ?? params\n }\n\n const template = dict[key] ?? fallback ?? key\n return format(template, resolvedParams)\n },\n }), [locale, dict])\n return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>\n}\n\nexport function useT() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useT must be used within I18nProvider')\n return ctx.t\n}\n\n/**\n * Like `useT`, but returns `undefined` instead of throwing when no\n * `I18nProvider` is in scope. Use where a translator is desirable but not\n * guaranteed (e.g. plumbing `t` into side-effect handlers that may run before\n * the provider mounts) \u2014 callers MUST provide a fallback.\n */\nexport function useOptionalT(): TranslateFn | undefined {\n const ctx = useContext(I18nContext)\n return ctx?.t\n}\n\nexport function useLocale() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useLocale must be used within I18nProvider')\n return ctx.locale\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { createContext, useContext, useMemo, type ReactNode } from 'react'\nimport type { Locale } from './config'\n\nexport type Dict = Record<string, string>\n\nexport type TranslateParams = Record<string, string | number>\n\nexport type TranslateFn = (\n key: string,\n fallbackOrParams?: string | TranslateParams,\n params?: TranslateParams\n) => string\n\nexport type I18nContextValue = {\n locale: Locale\n t: TranslateFn\n /** True when the locale is pinned via `OM_FORCE_LOCALE`; UI should hide switchers. */\n localeLocked: boolean\n}\n\nconst I18N_CONTEXT_KEY = '__openMercatoI18nContext'\n\ntype GlobalI18nContextStore = typeof globalThis & {\n [I18N_CONTEXT_KEY]?: ReturnType<typeof createContext<I18nContextValue | null>>\n}\n\nfunction getI18nContext() {\n const store = globalThis as GlobalI18nContextStore\n if (!store[I18N_CONTEXT_KEY]) {\n store[I18N_CONTEXT_KEY] = createContext<I18nContextValue | null>(null)\n }\n return store[I18N_CONTEXT_KEY]\n}\n\nconst I18nContext = getI18nContext()\n\nfunction format(template: string, params?: TranslateParams) {\n if (!params) return template\n return template.replace(/\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g, (_, doubleKey, singleKey) => {\n const key = doubleKey ?? singleKey\n if (!key) return _\n const value = params[key]\n if (value === undefined) {\n return doubleKey ? `{{${key}}}` : `{${key}}`\n }\n return String(value)\n })\n}\n\nexport function I18nProvider({ children, locale, dict, localeLocked = false }: { children: ReactNode; locale: Locale; dict: Dict; localeLocked?: boolean }) {\n const value = useMemo<I18nContextValue>(() => ({\n locale,\n localeLocked,\n t: (key, fallbackOrParams, params) => {\n let fallback: string | undefined\n let resolvedParams: TranslateParams | undefined\n\n if (typeof fallbackOrParams === 'string') {\n fallback = fallbackOrParams\n resolvedParams = params\n } else {\n resolvedParams = fallbackOrParams ?? params\n }\n\n const template = dict[key] ?? fallback ?? key\n return format(template, resolvedParams)\n },\n }), [locale, dict, localeLocked])\n return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>\n}\n\nexport function useT() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useT must be used within I18nProvider')\n return ctx.t\n}\n\n/**\n * Like `useT`, but returns `undefined` instead of throwing when no\n * `I18nProvider` is in scope. Use where a translator is desirable but not\n * guaranteed (e.g. plumbing `t` into side-effect handlers that may run before\n * the provider mounts) \u2014 callers MUST provide a fallback.\n */\nexport function useOptionalT(): TranslateFn | undefined {\n const ctx = useContext(I18nContext)\n return ctx?.t\n}\n\nexport function useLocale() {\n const ctx = useContext(I18nContext)\n if (!ctx) throw new Error('useLocale must be used within I18nProvider')\n return ctx.locale\n}\n\n/**\n * True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`\n * when no provider is in scope so callers can render unconditionally.\n */\nexport function useLocaleLocked() {\n const ctx = useContext(I18nContext)\n return ctx?.localeLocked ?? false\n}\n"],
|
|
5
|
+
"mappings": ";AAqES;AApET,SAAS,eAAe,YAAY,eAA+B;AAoBnE,MAAM,mBAAmB;AAMzB,SAAS,iBAAiB;AACxB,QAAM,QAAQ;AACd,MAAI,CAAC,MAAM,gBAAgB,GAAG;AAC5B,UAAM,gBAAgB,IAAI,cAAuC,IAAI;AAAA,EACvE;AACA,SAAO,MAAM,gBAAgB;AAC/B;AAEA,MAAM,cAAc,eAAe;AAEnC,SAAS,OAAO,UAAkB,QAA0B;AAC1D,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,SAAS,QAAQ,4BAA4B,CAAC,GAAG,WAAW,cAAc;AAC/E,UAAM,MAAM,aAAa;AACzB,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,QAAQ,OAAO,GAAG;AACxB,QAAI,UAAU,QAAW;AACvB,aAAO,YAAY,KAAK,GAAG,OAAO,IAAI,GAAG;AAAA,IAC3C;AACA,WAAO,OAAO,KAAK;AAAA,EACrB,CAAC;AACH;AAEO,SAAS,aAAa,EAAE,UAAU,QAAQ,MAAM,eAAe,MAAM,GAAgF;AAC1J,QAAM,QAAQ,QAA0B,OAAO;AAAA,IAC7C;AAAA,IACA;AAAA,IACA,GAAG,CAAC,KAAK,kBAAkB,WAAW;AACpC,UAAI;AACJ,UAAI;AAEJ,UAAI,OAAO,qBAAqB,UAAU;AACxC,mBAAW;AACX,yBAAiB;AAAA,MACnB,OAAO;AACL,yBAAiB,oBAAoB;AAAA,MACvC;AAEA,YAAM,WAAW,KAAK,GAAG,KAAK,YAAY;AAC1C,aAAO,OAAO,UAAU,cAAc;AAAA,IACxC;AAAA,EACF,IAAI,CAAC,QAAQ,MAAM,YAAY,CAAC;AAChC,SAAO,oBAAC,YAAY,UAAZ,EAAqB,OAAe,UAAS;AACvD;AAEO,SAAS,OAAO;AACrB,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,uCAAuC;AACjE,SAAO,IAAI;AACb;AAQO,SAAS,eAAwC;AACtD,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK;AACd;AAEO,SAAS,YAAY;AAC1B,QAAM,MAAM,WAAW,WAAW;AAClC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,4CAA4C;AACtE,SAAO,IAAI;AACb;AAMO,SAAS,kBAAkB;AAChC,QAAM,MAAM,WAAW,WAAW;AAClC,SAAO,KAAK,gBAAgB;AAC9B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/i18n/locale.js
CHANGED
|
@@ -22,6 +22,9 @@ function resolveLocaleFromCandidates(candidates) {
|
|
|
22
22
|
}
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
|
+
function resolveForcedLocale(env) {
|
|
26
|
+
return resolveSupportedLocale(env.OM_FORCE_LOCALE);
|
|
27
|
+
}
|
|
25
28
|
function resolveLocaleFromAcceptLanguage(acceptLanguage) {
|
|
26
29
|
if (typeof acceptLanguage !== "string" || acceptLanguage.trim().length === 0) {
|
|
27
30
|
return null;
|
|
@@ -42,6 +45,7 @@ function resolveLocaleFromAcceptLanguage(acceptLanguage) {
|
|
|
42
45
|
return resolveLocaleFromCandidates(rankedCandidates.map((entry) => entry.locale));
|
|
43
46
|
}
|
|
44
47
|
export {
|
|
48
|
+
resolveForcedLocale,
|
|
45
49
|
resolveLocaleFromAcceptLanguage,
|
|
46
50
|
resolveLocaleFromCandidates,
|
|
47
51
|
resolveSupportedLocale
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/i18n/locale.ts"],
|
|
4
|
-
"sourcesContent": ["import { locales, type Locale } from './config'\n\nfunction normalizeLocaleToken(value: string): string {\n return value.trim().toLowerCase().replace(/_/g, '-')\n}\n\nexport function resolveSupportedLocale(value: string | null | undefined): Locale | null {\n if (typeof value !== 'string') return null\n\n const normalized = normalizeLocaleToken(value)\n if (!normalized) return null\n\n if (locales.includes(normalized as Locale)) {\n return normalized as Locale\n }\n\n const baseLocale = normalized.split('-')[0]\n if (baseLocale && locales.includes(baseLocale as Locale)) {\n return baseLocale as Locale\n }\n\n return null\n}\n\nexport function resolveLocaleFromCandidates(\n candidates: Iterable<string | null | undefined>,\n): Locale | null {\n for (const candidate of candidates) {\n const resolved = resolveSupportedLocale(candidate)\n if (resolved) return resolved\n }\n return null\n}\n\nexport function resolveLocaleFromAcceptLanguage(\n acceptLanguage: string | null | undefined,\n): Locale | null {\n if (typeof acceptLanguage !== 'string' || acceptLanguage.trim().length === 0) {\n return null\n }\n\n const rankedCandidates = acceptLanguage\n .split(',')\n .map((entry, index) => {\n const [rawLocale, ...rawParams] = entry.split(';')\n const locale = rawLocale?.trim() ?? ''\n const qParam = rawParams.find((param) => param.trim().startsWith('q='))\n const parsedQ = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1\n const quality = Number.isFinite(parsedQ) ? Math.min(Math.max(parsedQ, 0), 1) : 1\n\n return { locale, quality, index }\n })\n .filter((entry) => entry.locale.length > 0 && entry.quality > 0)\n .sort((left, right) => {\n if (right.quality !== left.quality) {\n return right.quality - left.quality\n }\n return left.index - right.index\n })\n\n return resolveLocaleFromCandidates(rankedCandidates.map((entry) => entry.locale))\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,eAA4B;AAErC,SAAS,qBAAqB,OAAuB;AACnD,SAAO,MAAM,KAAK,EAAE,YAAY,EAAE,QAAQ,MAAM,GAAG;AACrD;AAEO,SAAS,uBAAuB,OAAiD;AACtF,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,aAAa,qBAAqB,KAAK;AAC7C,MAAI,CAAC,WAAY,QAAO;AAExB,MAAI,QAAQ,SAAS,UAAoB,GAAG;AAC1C,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,WAAW,MAAM,GAAG,EAAE,CAAC;AAC1C,MAAI,cAAc,QAAQ,SAAS,UAAoB,GAAG;AACxD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,4BACd,YACe;AACf,aAAW,aAAa,YAAY;AAClC,UAAM,WAAW,uBAAuB,SAAS;AACjD,QAAI,SAAU,QAAO;AAAA,EACvB;AACA,SAAO;AACT;AAEO,SAAS,gCACd,gBACe;AACf,MAAI,OAAO,mBAAmB,YAAY,eAAe,KAAK,EAAE,WAAW,GAAG;AAC5E,WAAO;AAAA,EACT;AAEA,QAAM,mBAAmB,eACtB,MAAM,GAAG,EACT,IAAI,CAAC,OAAO,UAAU;AACrB,UAAM,CAAC,WAAW,GAAG,SAAS,IAAI,MAAM,MAAM,GAAG;AACjD,UAAM,SAAS,WAAW,KAAK,KAAK;AACpC,UAAM,SAAS,UAAU,KAAK,CAAC,UAAU,MAAM,KAAK,EAAE,WAAW,IAAI,CAAC;AACtE,UAAM,UAAU,SAAS,OAAO,WAAW,OAAO,KAAK,EAAE,MAAM,CAAC,CAAC,IAAI;AACrE,UAAM,UAAU,OAAO,SAAS,OAAO,IAAI,KAAK,IAAI,KAAK,IAAI,SAAS,CAAC,GAAG,CAAC,IAAI;AAE/E,WAAO,EAAE,QAAQ,SAAS,MAAM;AAAA,EAClC,CAAC,EACA,OAAO,CAAC,UAAU,MAAM,OAAO,SAAS,KAAK,MAAM,UAAU,CAAC,EAC9D,KAAK,CAAC,MAAM,UAAU;AACrB,QAAI,MAAM,YAAY,KAAK,SAAS;AAClC,aAAO,MAAM,UAAU,KAAK;AAAA,IAC9B;AACA,WAAO,KAAK,QAAQ,MAAM;AAAA,EAC5B,CAAC;AAEH,SAAO,4BAA4B,iBAAiB,IAAI,CAAC,UAAU,MAAM,MAAM,CAAC;AAClF;",
|
|
4
|
+
"sourcesContent": ["import { locales, type Locale } from './config'\n\nfunction normalizeLocaleToken(value: string): string {\n return value.trim().toLowerCase().replace(/_/g, '-')\n}\n\nexport function resolveSupportedLocale(value: string | null | undefined): Locale | null {\n if (typeof value !== 'string') return null\n\n const normalized = normalizeLocaleToken(value)\n if (!normalized) return null\n\n if (locales.includes(normalized as Locale)) {\n return normalized as Locale\n }\n\n const baseLocale = normalized.split('-')[0]\n if (baseLocale && locales.includes(baseLocale as Locale)) {\n return baseLocale as Locale\n }\n\n return null\n}\n\nexport function resolveLocaleFromCandidates(\n candidates: Iterable<string | null | undefined>,\n): Locale | null {\n for (const candidate of candidates) {\n const resolved = resolveSupportedLocale(candidate)\n if (resolved) return resolved\n }\n return null\n}\n\n/**\n * Reads the optional `OM_FORCE_LOCALE` env override. When set to a supported\n * locale (e.g. `pl`), the whole app is pinned to it and cookie/Accept-Language\n * detection is bypassed. Unset (the default) \u2192 `null` \u2192 normal detection.\n * Pure: pass the env bag so it stays testable and safe to call server-side only.\n */\nexport function resolveForcedLocale(\n env: Record<string, string | undefined>,\n): Locale | null {\n return resolveSupportedLocale(env.OM_FORCE_LOCALE)\n}\n\nexport function resolveLocaleFromAcceptLanguage(\n acceptLanguage: string | null | undefined,\n): Locale | null {\n if (typeof acceptLanguage !== 'string' || acceptLanguage.trim().length === 0) {\n return null\n }\n\n const rankedCandidates = acceptLanguage\n .split(',')\n .map((entry, index) => {\n const [rawLocale, ...rawParams] = entry.split(';')\n const locale = rawLocale?.trim() ?? ''\n const qParam = rawParams.find((param) => param.trim().startsWith('q='))\n const parsedQ = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1\n const quality = Number.isFinite(parsedQ) ? Math.min(Math.max(parsedQ, 0), 1) : 1\n\n return { locale, quality, index }\n })\n .filter((entry) => entry.locale.length > 0 && entry.quality > 0)\n .sort((left, right) => {\n if (right.quality !== left.quality) {\n return right.quality - left.quality\n }\n return left.index - right.index\n })\n\n return resolveLocaleFromCandidates(rankedCandidates.map((entry) => entry.locale))\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAA4B;AAErC,SAAS,qBAAqB,OAAuB;AACnD,SAAO,MAAM,KAAK,EAAE,YAAY,EAAE,QAAQ,MAAM,GAAG;AACrD;AAEO,SAAS,uBAAuB,OAAiD;AACtF,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,aAAa,qBAAqB,KAAK;AAC7C,MAAI,CAAC,WAAY,QAAO;AAExB,MAAI,QAAQ,SAAS,UAAoB,GAAG;AAC1C,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,WAAW,MAAM,GAAG,EAAE,CAAC;AAC1C,MAAI,cAAc,QAAQ,SAAS,UAAoB,GAAG;AACxD,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,4BACd,YACe;AACf,aAAW,aAAa,YAAY;AAClC,UAAM,WAAW,uBAAuB,SAAS;AACjD,QAAI,SAAU,QAAO;AAAA,EACvB;AACA,SAAO;AACT;AAQO,SAAS,oBACd,KACe;AACf,SAAO,uBAAuB,IAAI,eAAe;AACnD;AAEO,SAAS,gCACd,gBACe;AACf,MAAI,OAAO,mBAAmB,YAAY,eAAe,KAAK,EAAE,WAAW,GAAG;AAC5E,WAAO;AAAA,EACT;AAEA,QAAM,mBAAmB,eACtB,MAAM,GAAG,EACT,IAAI,CAAC,OAAO,UAAU;AACrB,UAAM,CAAC,WAAW,GAAG,SAAS,IAAI,MAAM,MAAM,GAAG;AACjD,UAAM,SAAS,WAAW,KAAK,KAAK;AACpC,UAAM,SAAS,UAAU,KAAK,CAAC,UAAU,MAAM,KAAK,EAAE,WAAW,IAAI,CAAC;AACtE,UAAM,UAAU,SAAS,OAAO,WAAW,OAAO,KAAK,EAAE,MAAM,CAAC,CAAC,IAAI;AACrE,UAAM,UAAU,OAAO,SAAS,OAAO,IAAI,KAAK,IAAI,KAAK,IAAI,SAAS,CAAC,GAAG,CAAC,IAAI;AAE/E,WAAO,EAAE,QAAQ,SAAS,MAAM;AAAA,EAClC,CAAC,EACA,OAAO,CAAC,UAAU,MAAM,OAAO,SAAS,KAAK,MAAM,UAAU,CAAC,EAC9D,KAAK,CAAC,MAAM,UAAU;AACrB,QAAI,MAAM,YAAY,KAAK,SAAS;AAClC,aAAO,MAAM,UAAU,KAAK;AAAA,IAC9B;AACA,WAAO,KAAK,QAAQ,MAAM;AAAA,EAC5B,CAAC;AAEH,SAAO,4BAA4B,iBAAiB,IAAI,CAAC,UAAU,MAAM,MAAM,CAAC;AAClF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/i18n/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defaultLocale, locales } from "./config.js";
|
|
2
|
-
import { resolveLocaleFromAcceptLanguage } from "./locale.js";
|
|
2
|
+
import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from "./locale.js";
|
|
3
3
|
import { createFallbackTranslator, createTranslator } from "./translate.js";
|
|
4
4
|
import { getModules } from "../modules/registry.js";
|
|
5
5
|
import { loadAppDictionary } from "./app-dictionaries.js";
|
|
@@ -22,6 +22,8 @@ function flattenDictionary(source, prefix = "") {
|
|
|
22
22
|
return result;
|
|
23
23
|
}
|
|
24
24
|
async function detectLocale() {
|
|
25
|
+
const forced = resolveForcedLocale(process.env);
|
|
26
|
+
if (forced) return forced;
|
|
25
27
|
try {
|
|
26
28
|
const { cookies, headers } = await import("next/headers");
|
|
27
29
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/i18n/server.ts"],
|
|
4
|
-
"sourcesContent": ["import { defaultLocale, locales, type Locale } from './config'\nimport type { Dict } from './context'\nimport { resolveLocaleFromAcceptLanguage } from './locale'\nimport { createFallbackTranslator, createTranslator } from './translate'\nimport { getModules } from '../modules/registry'\nimport { loadAppDictionary } from './app-dictionaries'\nimport { getCachedDictionary, setCachedDictionary } from './dictionary-cache'\n\n// Re-export for backwards compatibility\nexport { registerModules, getModules } from '../modules/registry'\nexport { registerAppDictionaryLoader } from './app-dictionaries'\nexport { invalidateDictionaryCache } from './dictionary-cache'\n\nfunction flattenDictionary(source: unknown, prefix = ''): Dict {\n if (!source || typeof source !== 'object' || Array.isArray(source)) return {}\n const result: Dict = {}\n for (const [key, value] of Object.entries(source as Record<string, unknown>)) {\n if (!key) continue\n const nextKey = prefix ? `${prefix}.${key}` : key\n if (typeof value === 'string') {\n result[nextKey] = value\n } else if (value && typeof value === 'object' && !Array.isArray(value)) {\n Object.assign(result, flattenDictionary(value, nextKey))\n }\n }\n return result\n}\n\nexport async function detectLocale(): Promise<Locale> {\n // Dynamic import to avoid requiring Next.js in non-Next.js contexts (CLI, tests)\n try {\n const { cookies, headers } = await import('next/headers')\n try {\n const c = (await cookies()).get('locale')?.value\n if (c && locales.includes(c as Locale)) return c as Locale\n } catch {\n // cookies() may not be available outside request context (e.g., in tests)\n }\n try {\n const accept = (await headers()).get('accept-language') || ''\n const match = resolveLocaleFromAcceptLanguage(accept)\n if (match) return match\n } catch {\n // headers() may not be available outside request context (e.g., in tests)\n }\n } catch {\n // next/headers not available (CLI context)\n }\n return defaultLocale\n}\n\nexport async function loadDictionary(locale: Locale): Promise<Dict> {\n // Locale dictionaries are immutable at runtime, so the flatten+merge below\n // only needs to run once per locale. The cache is invalidated whenever\n // modules or the app dictionary loader are (re)registered.\n const cached = getCachedDictionary(locale)\n if (cached) return cached\n // Load from registry instead of @/ import (works in standalone packages)\n const baseRaw = await loadAppDictionary(locale)\n const merged: Dict = { ...flattenDictionary(baseRaw) }\n const modules = getModules()\n for (const m of modules) {\n const dict = m.translations?.[locale]\n if (dict) Object.assign(merged, flattenDictionary(dict))\n }\n setCachedDictionary(locale, merged)\n return merged\n}\n\nexport async function resolveTranslations() {\n const locale = await detectLocale()\n const dict = await loadDictionary(locale)\n const t = createTranslator(dict)\n const translate = createFallbackTranslator(dict)\n return { locale, dict, t, translate }\n}\n// Hint Next.js to keep this server-only; ignore if unavailable when running scripts outside Next.\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // noop: allows running generator scripts without Next's server-only package\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,eAAe,eAA4B;AAEpD,SAAS,uCAAuC;
|
|
4
|
+
"sourcesContent": ["import { defaultLocale, locales, type Locale } from './config'\nimport type { Dict } from './context'\nimport { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'\nimport { createFallbackTranslator, createTranslator } from './translate'\nimport { getModules } from '../modules/registry'\nimport { loadAppDictionary } from './app-dictionaries'\nimport { getCachedDictionary, setCachedDictionary } from './dictionary-cache'\n\n// Re-export for backwards compatibility\nexport { registerModules, getModules } from '../modules/registry'\nexport { registerAppDictionaryLoader } from './app-dictionaries'\nexport { invalidateDictionaryCache } from './dictionary-cache'\n\nfunction flattenDictionary(source: unknown, prefix = ''): Dict {\n if (!source || typeof source !== 'object' || Array.isArray(source)) return {}\n const result: Dict = {}\n for (const [key, value] of Object.entries(source as Record<string, unknown>)) {\n if (!key) continue\n const nextKey = prefix ? `${prefix}.${key}` : key\n if (typeof value === 'string') {\n result[nextKey] = value\n } else if (value && typeof value === 'object' && !Array.isArray(value)) {\n Object.assign(result, flattenDictionary(value, nextKey))\n }\n }\n return result\n}\n\nexport async function detectLocale(): Promise<Locale> {\n // Ops-level override: pin the whole app to one locale (default: unset).\n const forced = resolveForcedLocale(process.env)\n if (forced) return forced\n // Dynamic import to avoid requiring Next.js in non-Next.js contexts (CLI, tests)\n try {\n const { cookies, headers } = await import('next/headers')\n try {\n const c = (await cookies()).get('locale')?.value\n if (c && locales.includes(c as Locale)) return c as Locale\n } catch {\n // cookies() may not be available outside request context (e.g., in tests)\n }\n try {\n const accept = (await headers()).get('accept-language') || ''\n const match = resolveLocaleFromAcceptLanguage(accept)\n if (match) return match\n } catch {\n // headers() may not be available outside request context (e.g., in tests)\n }\n } catch {\n // next/headers not available (CLI context)\n }\n return defaultLocale\n}\n\nexport async function loadDictionary(locale: Locale): Promise<Dict> {\n // Locale dictionaries are immutable at runtime, so the flatten+merge below\n // only needs to run once per locale. The cache is invalidated whenever\n // modules or the app dictionary loader are (re)registered.\n const cached = getCachedDictionary(locale)\n if (cached) return cached\n // Load from registry instead of @/ import (works in standalone packages)\n const baseRaw = await loadAppDictionary(locale)\n const merged: Dict = { ...flattenDictionary(baseRaw) }\n const modules = getModules()\n for (const m of modules) {\n const dict = m.translations?.[locale]\n if (dict) Object.assign(merged, flattenDictionary(dict))\n }\n setCachedDictionary(locale, merged)\n return merged\n}\n\nexport async function resolveTranslations() {\n const locale = await detectLocale()\n const dict = await loadDictionary(locale)\n const t = createTranslator(dict)\n const translate = createFallbackTranslator(dict)\n return { locale, dict, t, translate }\n}\n// Hint Next.js to keep this server-only; ignore if unavailable when running scripts outside Next.\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // noop: allows running generator scripts without Next's server-only package\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe,eAA4B;AAEpD,SAAS,qBAAqB,uCAAuC;AACrE,SAAS,0BAA0B,wBAAwB;AAC3D,SAAS,kBAAkB;AAC3B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB,2BAA2B;AAGzD,SAAS,iBAAiB,cAAAA,mBAAkB;AAC5C,SAAS,mCAAmC;AAC5C,SAAS,iCAAiC;AAE1C,SAAS,kBAAkB,QAAiB,SAAS,IAAU;AAC7D,MAAI,CAAC,UAAU,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,EAAG,QAAO,CAAC;AAC5E,QAAM,SAAe,CAAC;AACtB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAiC,GAAG;AAC5E,QAAI,CAAC,IAAK;AACV,UAAM,UAAU,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK;AAC9C,QAAI,OAAO,UAAU,UAAU;AAC7B,aAAO,OAAO,IAAI;AAAA,IACpB,WAAW,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,GAAG;AACtE,aAAO,OAAO,QAAQ,kBAAkB,OAAO,OAAO,CAAC;AAAA,IACzD;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAsB,eAAgC;AAEpD,QAAM,SAAS,oBAAoB,QAAQ,GAAG;AAC9C,MAAI,OAAQ,QAAO;AAEnB,MAAI;AACF,UAAM,EAAE,SAAS,QAAQ,IAAI,MAAM,OAAO,cAAc;AACxD,QAAI;AACF,YAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,QAAQ,GAAG;AAC3C,UAAI,KAAK,QAAQ,SAAS,CAAW,EAAG,QAAO;AAAA,IACjD,QAAQ;AAAA,IAER;AACA,QAAI;AACF,YAAM,UAAU,MAAM,QAAQ,GAAG,IAAI,iBAAiB,KAAK;AAC3D,YAAM,QAAQ,gCAAgC,MAAM;AACpD,UAAI,MAAO,QAAO;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAEA,eAAsB,eAAe,QAA+B;AAIlE,QAAM,SAAS,oBAAoB,MAAM;AACzC,MAAI,OAAQ,QAAO;AAEnB,QAAM,UAAU,MAAM,kBAAkB,MAAM;AAC9C,QAAM,SAAe,EAAE,GAAG,kBAAkB,OAAO,EAAE;AACrD,QAAM,UAAU,WAAW;AAC3B,aAAW,KAAK,SAAS;AACvB,UAAM,OAAO,EAAE,eAAe,MAAM;AACpC,QAAI,KAAM,QAAO,OAAO,QAAQ,kBAAkB,IAAI,CAAC;AAAA,EACzD;AACA,sBAAoB,QAAQ,MAAM;AAClC,SAAO;AACT;AAEA,eAAsB,sBAAsB;AAC1C,QAAM,SAAS,MAAM,aAAa;AAClC,QAAM,OAAO,MAAM,eAAe,MAAM;AACxC,QAAM,IAAI,iBAAiB,IAAI;AAC/B,QAAM,YAAY,yBAAyB,IAAI;AAC/C,SAAO,EAAE,QAAQ,MAAM,GAAG,UAAU;AACtC;AAEA,IAAI;AAEF,UAAQ,aAAa;AACvB,QAAQ;AAER;",
|
|
6
6
|
"names": ["getModules"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6381.1.042df302c3'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6381.1.042df302c3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@mikro-orm/core": "^7.1.4",
|
|
94
94
|
"@mikro-orm/decorators": "^7.1.4",
|
|
95
95
|
"@mikro-orm/postgresql": "^7.1.4",
|
|
96
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
96
|
+
"@open-mercato/cache": "0.6.6-develop.6381.1.042df302c3",
|
|
97
97
|
"dotenv": "^17.4.2",
|
|
98
98
|
"rate-limiter-flexible": "^11.2.0",
|
|
99
99
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveForcedLocale } from '../locale'
|
|
2
|
+
|
|
3
|
+
describe('resolveForcedLocale', () => {
|
|
4
|
+
it('returns null when OM_FORCE_LOCALE is unset (default: no forcing)', () => {
|
|
5
|
+
expect(resolveForcedLocale({})).toBeNull()
|
|
6
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: '' })).toBeNull()
|
|
7
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: undefined })).toBeNull()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns the forced locale when set to a supported value', () => {
|
|
11
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'pl' })).toBe('pl')
|
|
12
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'de' })).toBe('de')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('normalizes region and casing to a supported base locale', () => {
|
|
16
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'PL' })).toBe('pl')
|
|
17
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'pl-PL' })).toBe('pl')
|
|
18
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'en_US' })).toBe('en')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns null for unsupported locales rather than forcing garbage', () => {
|
|
22
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'fr' })).toBeNull()
|
|
23
|
+
expect(resolveForcedLocale({ OM_FORCE_LOCALE: 'not-a-locale' })).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|
package/src/lib/i18n/context.tsx
CHANGED
|
@@ -15,6 +15,8 @@ export type TranslateFn = (
|
|
|
15
15
|
export type I18nContextValue = {
|
|
16
16
|
locale: Locale
|
|
17
17
|
t: TranslateFn
|
|
18
|
+
/** True when the locale is pinned via `OM_FORCE_LOCALE`; UI should hide switchers. */
|
|
19
|
+
localeLocked: boolean
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const I18N_CONTEXT_KEY = '__openMercatoI18nContext'
|
|
@@ -46,9 +48,10 @@ function format(template: string, params?: TranslateParams) {
|
|
|
46
48
|
})
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
export function I18nProvider({ children, locale, dict }: { children: ReactNode; locale: Locale; dict: Dict }) {
|
|
51
|
+
export function I18nProvider({ children, locale, dict, localeLocked = false }: { children: ReactNode; locale: Locale; dict: Dict; localeLocked?: boolean }) {
|
|
50
52
|
const value = useMemo<I18nContextValue>(() => ({
|
|
51
53
|
locale,
|
|
54
|
+
localeLocked,
|
|
52
55
|
t: (key, fallbackOrParams, params) => {
|
|
53
56
|
let fallback: string | undefined
|
|
54
57
|
let resolvedParams: TranslateParams | undefined
|
|
@@ -63,7 +66,7 @@ export function I18nProvider({ children, locale, dict }: { children: ReactNode;
|
|
|
63
66
|
const template = dict[key] ?? fallback ?? key
|
|
64
67
|
return format(template, resolvedParams)
|
|
65
68
|
},
|
|
66
|
-
}), [locale, dict])
|
|
69
|
+
}), [locale, dict, localeLocked])
|
|
67
70
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -89,3 +92,12 @@ export function useLocale() {
|
|
|
89
92
|
if (!ctx) throw new Error('useLocale must be used within I18nProvider')
|
|
90
93
|
return ctx.locale
|
|
91
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when the active locale is pinned via `OM_FORCE_LOCALE`. Returns `false`
|
|
98
|
+
* when no provider is in scope so callers can render unconditionally.
|
|
99
|
+
*/
|
|
100
|
+
export function useLocaleLocked() {
|
|
101
|
+
const ctx = useContext(I18nContext)
|
|
102
|
+
return ctx?.localeLocked ?? false
|
|
103
|
+
}
|
package/src/lib/i18n/locale.ts
CHANGED
|
@@ -32,6 +32,18 @@ export function resolveLocaleFromCandidates(
|
|
|
32
32
|
return null
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Reads the optional `OM_FORCE_LOCALE` env override. When set to a supported
|
|
37
|
+
* locale (e.g. `pl`), the whole app is pinned to it and cookie/Accept-Language
|
|
38
|
+
* detection is bypassed. Unset (the default) → `null` → normal detection.
|
|
39
|
+
* Pure: pass the env bag so it stays testable and safe to call server-side only.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveForcedLocale(
|
|
42
|
+
env: Record<string, string | undefined>,
|
|
43
|
+
): Locale | null {
|
|
44
|
+
return resolveSupportedLocale(env.OM_FORCE_LOCALE)
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
export function resolveLocaleFromAcceptLanguage(
|
|
36
48
|
acceptLanguage: string | null | undefined,
|
|
37
49
|
): Locale | null {
|
package/src/lib/i18n/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultLocale, locales, type Locale } from './config'
|
|
2
2
|
import type { Dict } from './context'
|
|
3
|
-
import { resolveLocaleFromAcceptLanguage } from './locale'
|
|
3
|
+
import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'
|
|
4
4
|
import { createFallbackTranslator, createTranslator } from './translate'
|
|
5
5
|
import { getModules } from '../modules/registry'
|
|
6
6
|
import { loadAppDictionary } from './app-dictionaries'
|
|
@@ -27,6 +27,9 @@ function flattenDictionary(source: unknown, prefix = ''): Dict {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export async function detectLocale(): Promise<Locale> {
|
|
30
|
+
// Ops-level override: pin the whole app to one locale (default: unset).
|
|
31
|
+
const forced = resolveForcedLocale(process.env)
|
|
32
|
+
if (forced) return forced
|
|
30
33
|
// Dynamic import to avoid requiring Next.js in non-Next.js contexts (CLI, tests)
|
|
31
34
|
try {
|
|
32
35
|
const { cookies, headers } = await import('next/headers')
|