@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7275.1.f772b944d4

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.
@@ -56,12 +56,12 @@ async function loadDictionary(locale) {
56
56
  const needsDefaultLocaleBase = locale !== defaultLocale && !locales.includes(locale);
57
57
  const merged = needsDefaultLocaleBase ? { ...await loadDictionary(defaultLocale) } : {};
58
58
  const baseRaw = await loadAppDictionary(locale);
59
- Object.assign(merged, flattenDictionary(baseRaw));
60
59
  const modules = tryGetModules() ?? [];
61
60
  for (const m of modules) {
62
61
  const dict = m.translations?.[locale];
63
62
  if (dict) Object.assign(merged, flattenDictionary(dict));
64
63
  }
64
+ Object.assign(merged, flattenDictionary(baseRaw));
65
65
  setCachedDictionary(locale, merged);
66
66
  return merged;
67
67
  }
@@ -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 { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'\nimport { createFallbackTranslator, createTranslator } from './translate'\nimport { tryGetModules } from '../modules/registry'\nimport { loadAppDictionary } from './app-dictionaries'\nimport { getCachedDictionary, setCachedDictionary } from './dictionary-cache'\nimport { getSupportedLocales, resolveSupportedLocalesForRequest } from './locale-registry'\n\n// Re-export for backwards compatibility\nexport { registerModules, getModules } from '../modules/registry'\nexport { registerAppDictionaryLoader } from './app-dictionaries'\nexport { invalidateDictionaryCache } from './dictionary-cache'\nexport {\n registerLocales,\n getSupportedLocales,\n registerSupportedLocalesResolver,\n resolveSupportedLocalesForRequest,\n} from './locale-registry'\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 type DetectLocaleOptions = {\n /**\n * Restrict detection to this set \u2014 typically the current tenant's selection,\n * resolved by the caller via `resolveSupportedLocalesForRequest()`. Omitted,\n * detection uses the process-wide supported set, which is the prior behaviour.\n */\n supportedLocales?: readonly Locale[]\n}\n\nexport async function detectLocale(options?: DetectLocaleOptions): 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 const supported = options?.supportedLocales ?? getSupportedLocales()\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 && supported.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 // Matched against the served set rather than the process-wide one, so a\n // header like `de, en` on a tenant that serves only `en` picks `en`\n // instead of matching `de` first and then discarding the whole header.\n const match = resolveLocaleFromAcceptLanguage(accept, supported)\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 // The caller may have narrowed the set past the default locale, and returning\n // a locale outside the served set would render a page whose own language\n // switcher does not offer the language it is written in.\n // `resolveSupportedLocalesForRequest` keeps `defaultLocale` in the set for\n // exactly this reason; the `supported[0]` arm covers a caller that narrowed by\n // hand and did not.\n if (supported.includes(defaultLocale)) return defaultLocale\n return supported[0] ?? 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 // A locale the platform does not ship has no dictionaries of its own yet, and\n // roughly a quarter of `t()` call sites pass no inline fallback \u2014 without a\n // base layer those render as raw keys. Layering the default locale underneath\n // makes an app- or operator-added locale degrade to English instead, which is\n // what every comparable platform does. Shipped locales skip this entirely and\n // keep their previous merge semantics byte for byte.\n const needsDefaultLocaleBase =\n locale !== defaultLocale && !(locales as readonly string[]).includes(locale)\n const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}\n // Load from registry instead of @/ import (works in standalone packages)\n const baseRaw = await loadAppDictionary(locale)\n Object.assign(merged, flattenDictionary(baseRaw))\n // Route handlers translate their responses, so they resolve a dictionary even\n // when they are exercised in isolation without a bootstrapped registry. The\n // app dictionary alone is the right degraded answer there \u2014 `registerModules`\n // invalidates this cache, so a later bootstrap still gets the merged result.\n const modules = tryGetModules() ?? []\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\n/**\n * Detect the locale and load its dictionary in one step.\n *\n * `options` is forwarded to `detectLocale`, so a caller that has already\n * resolved the request's served set (a layout mounting its own `I18nProvider`)\n * detects against that set instead of the process-wide one. Omitted \u2014 which is\n * every route handler \u2014 behaviour is unchanged and no tenant lookup is made.\n */\nexport async function resolveTranslations(options?: DetectLocaleOptions) {\n const locale = await detectLocale(options)\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,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB,2BAA2B;AACzD,SAAS,2BAA8D;AAGvE,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,mCAAmC;AAC5C,SAAS,iCAAiC;AAC1C;AAAA,EACE;AAAA,EACA,uBAAAA;AAAA,EACA;AAAA,EACA,qCAAAC;AAAA,OACK;AAEP,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;AAWA,eAAsB,aAAa,SAAgD;AAEjF,QAAM,SAAS,oBAAoB,QAAQ,GAAG;AAC9C,MAAI,OAAQ,QAAO;AACnB,QAAM,YAAY,SAAS,oBAAoB,oBAAoB;AAEnE,MAAI;AACF,UAAM,EAAE,SAAS,QAAQ,IAAI,MAAM,OAAO,cAAc;AACxD,QAAI;AACF,YAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,QAAQ,GAAG;AAC3C,UAAI,KAAK,UAAU,SAAS,CAAW,EAAG,QAAO;AAAA,IACnD,QAAQ;AAAA,IAER;AACA,QAAI;AACF,YAAM,UAAU,MAAM,QAAQ,GAAG,IAAI,iBAAiB,KAAK;AAI3D,YAAM,QAAQ,gCAAgC,QAAQ,SAAS;AAC/D,UAAI,MAAO,QAAO;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF,QAAQ;AAAA,EAER;AAOA,MAAI,UAAU,SAAS,aAAa,EAAG,QAAO;AAC9C,SAAO,UAAU,CAAC,KAAK;AACzB;AAEA,eAAsB,eAAe,QAA+B;AAIlE,QAAM,SAAS,oBAAoB,MAAM;AACzC,MAAI,OAAQ,QAAO;AAOnB,QAAM,yBACJ,WAAW,iBAAiB,CAAE,QAA8B,SAAS,MAAM;AAC7E,QAAM,SAAe,yBAAyB,EAAE,GAAI,MAAM,eAAe,aAAa,EAAG,IAAI,CAAC;AAE9F,QAAM,UAAU,MAAM,kBAAkB,MAAM;AAC9C,SAAO,OAAO,QAAQ,kBAAkB,OAAO,CAAC;AAKhD,QAAM,UAAU,cAAc,KAAK,CAAC;AACpC,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;AAUA,eAAsB,oBAAoB,SAA+B;AACvE,QAAM,SAAS,MAAM,aAAa,OAAO;AACzC,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;",
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 { tryGetModules } from '../modules/registry'\nimport { loadAppDictionary } from './app-dictionaries'\nimport { getCachedDictionary, setCachedDictionary } from './dictionary-cache'\nimport { getSupportedLocales, resolveSupportedLocalesForRequest } from './locale-registry'\n\n// Re-export for backwards compatibility\nexport { registerModules, getModules } from '../modules/registry'\nexport { registerAppDictionaryLoader } from './app-dictionaries'\nexport { invalidateDictionaryCache } from './dictionary-cache'\nexport {\n registerLocales,\n getSupportedLocales,\n registerSupportedLocalesResolver,\n resolveSupportedLocalesForRequest,\n} from './locale-registry'\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 type DetectLocaleOptions = {\n /**\n * Restrict detection to this set \u2014 typically the current tenant's selection,\n * resolved by the caller via `resolveSupportedLocalesForRequest()`. Omitted,\n * detection uses the process-wide supported set, which is the prior behaviour.\n */\n supportedLocales?: readonly Locale[]\n}\n\nexport async function detectLocale(options?: DetectLocaleOptions): 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 const supported = options?.supportedLocales ?? getSupportedLocales()\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 && supported.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 // Matched against the served set rather than the process-wide one, so a\n // header like `de, en` on a tenant that serves only `en` picks `en`\n // instead of matching `de` first and then discarding the whole header.\n const match = resolveLocaleFromAcceptLanguage(accept, supported)\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 // The caller may have narrowed the set past the default locale, and returning\n // a locale outside the served set would render a page whose own language\n // switcher does not offer the language it is written in.\n // `resolveSupportedLocalesForRequest` keeps `defaultLocale` in the set for\n // exactly this reason; the `supported[0]` arm covers a caller that narrowed by\n // hand and did not.\n if (supported.includes(defaultLocale)) return defaultLocale\n return supported[0] ?? 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 // A locale the platform does not ship has no dictionaries of its own yet, and\n // roughly a quarter of `t()` call sites pass no inline fallback \u2014 without a\n // base layer those render as raw keys. Layering the default locale underneath\n // makes an app- or operator-added locale degrade to English instead, which is\n // what every comparable platform does. Shipped locales skip this entirely and\n // keep their previous merge semantics byte for byte.\n const needsDefaultLocaleBase =\n locale !== defaultLocale && !(locales as readonly string[]).includes(locale)\n const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}\n // Load from registry instead of @/ import (works in standalone packages).\n // Awaited BEFORE tryGetModules() below: the host app's dictionary loader\n // (see apps/mercato/src/lib/i18n/register-dictionary-loader.ts) lazily\n // imports this locale's per-module translation bundle and registers it via\n // `registerModules()` as a side effect of resolving \u2014 reading the module\n // registry first would race a locale's first-ever load and permanently\n // cache a dictionary missing every lazily-registered module's translations\n // for it (the registration event that would normally invalidate the cache\n // fires too early, before this call has anything cached to invalidate).\n const baseRaw = await loadAppDictionary(locale)\n // Route handlers translate their responses, so they resolve a dictionary even\n // when they are exercised in isolation without a bootstrapped registry. Module\n // dictionaries are layered first so the host app dictionary \u2014 merged last,\n // below \u2014 is always the final word and can override any module-defined key.\n const modules = tryGetModules() ?? []\n for (const m of modules) {\n const dict = m.translations?.[locale]\n if (dict) Object.assign(merged, flattenDictionary(dict))\n }\n Object.assign(merged, flattenDictionary(baseRaw))\n setCachedDictionary(locale, merged)\n return merged\n}\n\n/**\n * Detect the locale and load its dictionary in one step.\n *\n * `options` is forwarded to `detectLocale`, so a caller that has already\n * resolved the request's served set (a layout mounting its own `I18nProvider`)\n * detects against that set instead of the process-wide one. Omitted \u2014 which is\n * every route handler \u2014 behaviour is unchanged and no tenant lookup is made.\n */\nexport async function resolveTranslations(options?: DetectLocaleOptions) {\n const locale = await detectLocale(options)\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,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB,2BAA2B;AACzD,SAAS,2BAA8D;AAGvE,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,mCAAmC;AAC5C,SAAS,iCAAiC;AAC1C;AAAA,EACE;AAAA,EACA,uBAAAA;AAAA,EACA;AAAA,EACA,qCAAAC;AAAA,OACK;AAEP,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;AAWA,eAAsB,aAAa,SAAgD;AAEjF,QAAM,SAAS,oBAAoB,QAAQ,GAAG;AAC9C,MAAI,OAAQ,QAAO;AACnB,QAAM,YAAY,SAAS,oBAAoB,oBAAoB;AAEnE,MAAI;AACF,UAAM,EAAE,SAAS,QAAQ,IAAI,MAAM,OAAO,cAAc;AACxD,QAAI;AACF,YAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,QAAQ,GAAG;AAC3C,UAAI,KAAK,UAAU,SAAS,CAAW,EAAG,QAAO;AAAA,IACnD,QAAQ;AAAA,IAER;AACA,QAAI;AACF,YAAM,UAAU,MAAM,QAAQ,GAAG,IAAI,iBAAiB,KAAK;AAI3D,YAAM,QAAQ,gCAAgC,QAAQ,SAAS;AAC/D,UAAI,MAAO,QAAO;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF,QAAQ;AAAA,EAER;AAOA,MAAI,UAAU,SAAS,aAAa,EAAG,QAAO;AAC9C,SAAO,UAAU,CAAC,KAAK;AACzB;AAEA,eAAsB,eAAe,QAA+B;AAIlE,QAAM,SAAS,oBAAoB,MAAM;AACzC,MAAI,OAAQ,QAAO;AAOnB,QAAM,yBACJ,WAAW,iBAAiB,CAAE,QAA8B,SAAS,MAAM;AAC7E,QAAM,SAAe,yBAAyB,EAAE,GAAI,MAAM,eAAe,aAAa,EAAG,IAAI,CAAC;AAU9F,QAAM,UAAU,MAAM,kBAAkB,MAAM;AAK9C,QAAM,UAAU,cAAc,KAAK,CAAC;AACpC,aAAW,KAAK,SAAS;AACvB,UAAM,OAAO,EAAE,eAAe,MAAM;AACpC,QAAI,KAAM,QAAO,OAAO,QAAQ,kBAAkB,IAAI,CAAC;AAAA,EACzD;AACA,SAAO,OAAO,QAAQ,kBAAkB,OAAO,CAAC;AAChD,sBAAoB,QAAQ,MAAM;AAClC,SAAO;AACT;AAUA,eAAsB,oBAAoB,SAA+B;AACvE,QAAM,SAAS,MAAM,aAAa,OAAO;AACzC,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": ["getSupportedLocales", "resolveSupportedLocalesForRequest"]
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7274.1.21a0083828";
1
+ const APP_VERSION = "0.8.1-develop.7275.1.f772b944d4";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.8.1-develop.7274.1.21a0083828';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7275.1.f772b944d4';\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.8.1-develop.7274.1.21a0083828",
3
+ "version": "0.8.1-develop.7275.1.f772b944d4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@
113
113
  "@mikro-orm/core": "^7.1.14",
114
114
  "@mikro-orm/decorators": "^7.1.14",
115
115
  "@mikro-orm/postgresql": "^7.1.14",
116
- "@open-mercato/cache": "0.8.1-develop.7274.1.21a0083828",
116
+ "@open-mercato/cache": "0.8.1-develop.7275.1.f772b944d4",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -50,6 +50,55 @@ describe('dictionary fallback for locales the platform does not ship', () => {
50
50
  })
51
51
  })
52
52
 
53
+ describe('host app override precedence', () => {
54
+ it('lets the host app dictionary override a module-defined key', async () => {
55
+ registerModules([{ translations: { en: { greeting: 'Hello from module' } } }] as any)
56
+
57
+ const en = await loadDictionary('en')
58
+
59
+ // The host's own locale file must win — a module MUST NOT be able to
60
+ // silently shadow a key the host app explicitly set (issue #5995).
61
+ expect(en.greeting).toBe('Hello')
62
+ })
63
+
64
+ it('still falls back to a module key the host app does not define', async () => {
65
+ registerModules([{ translations: { en: { moduleOnly: 'Module value' } } }] as any)
66
+
67
+ const en = await loadDictionary('en')
68
+
69
+ expect(en.moduleOnly).toBe('Module value')
70
+ expect(en.greeting).toBe('Hello')
71
+ })
72
+
73
+ it('sees a module registered as a side effect of resolving the app loader on the first load of a locale', async () => {
74
+ // Mirrors apps/mercato/src/lib/i18n/register-dictionary-loader.ts: the
75
+ // registered loader lazily imports the locale's per-module translation
76
+ // bundle and calls registerModules() as a side effect, after an internal
77
+ // await. loadDictionary() MUST await loadAppDictionary() before reading
78
+ // tryGetModules() — reading the registry first would race the locale's
79
+ // first-ever load and permanently cache a dictionary missing this
80
+ // lazily-registered module's translations (regression fixed in
81
+ // 4f3a8e255).
82
+ registerAppDictionaryLoader(async (locale: Locale) => {
83
+ if (locale === 'pl') {
84
+ await Promise.resolve()
85
+ registerModules([
86
+ { translations: { pl: { greeting: 'Cześć (module)', fromModule: 'Z modułu' } } },
87
+ ] as any)
88
+ }
89
+ return APP_DICTIONARIES[locale] ?? {}
90
+ })
91
+
92
+ const pl = await loadDictionary('pl')
93
+
94
+ // The module key is present on the very first load, proving the module
95
+ // registered inside the loader was read after it resolved.
96
+ expect(pl.fromModule).toBe('Z modułu')
97
+ // The app dictionary still wins the collision (issue #5995).
98
+ expect(pl.greeting).toBe('Cześć')
99
+ })
100
+ })
101
+
53
102
  describe('an app-registered locale', () => {
54
103
  it('falls back to the default locale instead of rendering raw keys', async () => {
55
104
  registerLocales(['cs'])
@@ -94,18 +94,26 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
94
94
  const needsDefaultLocaleBase =
95
95
  locale !== defaultLocale && !(locales as readonly string[]).includes(locale)
96
96
  const merged: Dict = needsDefaultLocaleBase ? { ...(await loadDictionary(defaultLocale)) } : {}
97
- // Load from registry instead of @/ import (works in standalone packages)
97
+ // Load from registry instead of @/ import (works in standalone packages).
98
+ // Awaited BEFORE tryGetModules() below: the host app's dictionary loader
99
+ // (see apps/mercato/src/lib/i18n/register-dictionary-loader.ts) lazily
100
+ // imports this locale's per-module translation bundle and registers it via
101
+ // `registerModules()` as a side effect of resolving — reading the module
102
+ // registry first would race a locale's first-ever load and permanently
103
+ // cache a dictionary missing every lazily-registered module's translations
104
+ // for it (the registration event that would normally invalidate the cache
105
+ // fires too early, before this call has anything cached to invalidate).
98
106
  const baseRaw = await loadAppDictionary(locale)
99
- Object.assign(merged, flattenDictionary(baseRaw))
100
107
  // Route handlers translate their responses, so they resolve a dictionary even
101
- // when they are exercised in isolation without a bootstrapped registry. The
102
- // app dictionary alone is the right degraded answer there — `registerModules`
103
- // invalidates this cache, so a later bootstrap still gets the merged result.
108
+ // when they are exercised in isolation without a bootstrapped registry. Module
109
+ // dictionaries are layered first so the host app dictionary — merged last,
110
+ // below — is always the final word and can override any module-defined key.
104
111
  const modules = tryGetModules() ?? []
105
112
  for (const m of modules) {
106
113
  const dict = m.translations?.[locale]
107
114
  if (dict) Object.assign(merged, flattenDictionary(dict))
108
115
  }
116
+ Object.assign(merged, flattenDictionary(baseRaw))
109
117
  setCachedDictionary(locale, merged)
110
118
  return merged
111
119
  }