langsys-js-typescript 0.2.3 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## 0.3.0 - 2026-07-03
2
+
3
+ Full CLDR compliance, matching the backend's CLDR migration. Everything rides on the platform's native `Intl` APIs — no new dependencies, no bundled locale data.
4
+
5
+ ### Changed
6
+
7
+ - **Locale identifiers are now BCP 47 canonical everywhere.** `en-us` → `en-US`, `zh-hant-tw` → `zh-Hant-TW`, `en_US` → `en-US`, legacy aliases resolved (`iw` → `he`). Canonicalization happens at every input boundary (init config, user-locale store emissions, `detectPreferredLocale`, API params), so the canonical form is what goes on the wire and what keys every internal cache. Lowercase locales in existing configs keep working — they're canonicalized on the way in.
8
+ - **`detectPreferredLocale` matching is script-aware** via CLDR likely-subtags (`Intl.Locale.maximize()`). `es-MX` matches a supported `es-ES` or `es`; `zh-TW` matches `zh-Hant` and never `zh-Hans` — cross-script fallbacks are refused instead of guessed. Previously matching truncated to the bare language code, which could hand a Traditional Chinese reader a Simplified catalog.
9
+ - **Simple `{name}` interpolation formats numbers and Dates per the target locale's CLDR rules.** Numbers go through `Intl.NumberFormat` (`1234.5` → `1.234,5` in `de-DE`); Dates through `Intl.DateTimeFormat` medium date style (previously ISO 8601). Pass values as strings to opt out — e.g. IDs that must not get grouping separators.
10
+
11
+ ### Fixed
12
+
13
+ - **Style-less ICU arguments (`{n, number}`, `{d, date}`, `{t, time}`) now format correctly.** The ICU detector required a style argument, so these valid ICU forms fell through to simple interpolation and rendered as literal `{n, number}` text.
14
+ - Locale equality checks (fetch dedup, country/currency/dial-code caches) no longer miss when the same locale arrives with different casing.
15
+
16
+ ### Added
17
+
18
+ - `canonicalizeLocale(locale)` — exported BCP 47 canonicalizer, useful when seeding your own `UserLocaleStore`.
19
+
1
20
  ## 0.2.1 - 2026-06-11
2
21
 
3
22
  ### Changed
package/README.md CHANGED
@@ -29,14 +29,14 @@ import { LangsysApp, createSignal, t } from 'langsys-js-typescript';
29
29
 
30
30
  // 1. A reactive holder for the user's current locale. Use `createSignal` if you
31
31
  // have nothing of your own — any object with subscribe/set/update/get works.
32
- const userLocale = createSignal('en-us');
32
+ const userLocale = createSignal('en-US');
33
33
 
34
34
  // 2. Initialize
35
35
  const response = await LangsysApp.init({
36
36
  projectid: process.env.LANGSYS_PROJECT_ID!,
37
37
  key: process.env.LANGSYS_API_KEY!,
38
38
  UserLocaleStore: userLocale,
39
- baseLocale: 'en-us',
39
+ baseLocale: 'en-US',
40
40
  debug: false,
41
41
  });
42
42
 
@@ -51,7 +51,7 @@ document.querySelector('#greeting')!.textContent =
51
51
  t('Hello, {name}!', 'Greetings', { name: 'Sarah' });
52
52
 
53
53
  // 5. Change locale — all subscribed consumers re-translate
54
- userLocale.set('es-es');
54
+ userLocale.set('es-ES');
55
55
  ```
56
56
 
57
57
  ### How `t()` works
@@ -68,9 +68,9 @@ t('Hello, {name}!', 'Greetings', {}); // ❌ Property '
68
68
  t('Hello, {name}!', 'Greetings', { name: 'x', extra: 'y' }); // ❌ Unknown property 'extra'
69
69
  ```
70
70
 
71
- Allowed value types: `string | number | Date | boolean`. Dates serialize to ISO 8601.
71
+ Allowed value types: `string | number | Date | boolean`. Numbers and Dates format per the active locale's CLDR rules (`1234.5` → `1.234,5` in `de-DE`; Dates use the medium date style). Pass values as strings to opt out — e.g. IDs and codes that must not get grouping separators.
72
72
 
73
- > Future versions will swap the simple `{name}` runtime for ICU MessageFormat — adding plural / select / date formatting — without changing the public signature. Today's `t('{count} items', 'Cart', { count })` will evolve to `t('{count, plural, one {# item} other {# items}}', 'Cart', { count })`.
73
+ > Translations containing ICU MessageFormat syntax — `{count, plural, one {# item} other {# items}}`, `{gender, select, …}`, `{n, number}`, `{d, date}` — are rendered with full CLDR plural rules for the active locale (Arabic's six categories, Russian's four, etc.). Plain `{name}` slots keep working unchanged.
74
74
 
75
75
  #### Categorization disambiguates context
76
76
 
@@ -167,9 +167,9 @@ await LangsysApp.init({
167
167
  projectid: env.LANGSYS_PROJECT_ID,
168
168
  key: env.LANGSYS_API_KEY,
169
169
  UserLocaleStore: userLocale,
170
- baseLocale: 'en-us',
170
+ baseLocale: 'en-US',
171
171
  initialTranslations, // fetched on the server
172
- initialTranslationsLocale: 'es-es',
172
+ initialTranslationsLocale: 'es-ES',
173
173
  ssrTokenStrategy: 'client', // 'client' | 'server' | 'auto'
174
174
  });
175
175
  ```
@@ -187,7 +187,7 @@ const locale = LangsysApp.detectPreferredLocale();
187
187
  // SSR — parse Accept-Language header
188
188
  const locale = LangsysApp.detectPreferredLocale(req.headers['accept-language']);
189
189
 
190
- // Match against your app's supported locales, falling back to language-only matches
190
+ // Match against your app's supported locales (script-aware CLDR matching)
191
191
  const supported = (await LangsysApp.getLocalesFlat()).map((l) => l.code);
192
192
  const locale = LangsysApp.detectPreferredLocale(
193
193
  req.headers['accept-language'],
@@ -195,7 +195,7 @@ const locale = LangsysApp.detectPreferredLocale(
195
195
  );
196
196
  ```
197
197
 
198
- The matcher tries exact match first (`en-US`), then language-only (`en` matches `en-GB`). When you pass `supported` and none match, it falls back to the user's top preference (normalized); it returns `false` only when no preference can be determined at all.
198
+ The matcher tries an exact match first (`en-US`), then falls back to same-language-and-script matching using CLDR likely-subtags data (`es-MX` matches `es-ES`; `zh-TW` matches `zh-Hant` but never `zh-Hans` — cross-script fallbacks are deliberately refused). All results come back in BCP 47 canonical form (`en-US`, `zh-Hant-TW`). When you pass `supported` and none match, it falls back to the user's top preference (canonicalized); it returns `false` only when no preference can be determined at all.
199
199
 
200
200
  ## Localized country / currency / locale lists
201
201
 
@@ -204,7 +204,7 @@ const countries = await LangsysApp.getCountries(); // [{ code, label }, ..
204
204
  const dialCodes = await LangsysApp.getDialCodes(); // [{ country_code, dial_code, name }, ...]
205
205
  const currencies = await LangsysApp.getCurrencies(); // [{ code, name, symbol, ... }, ...]
206
206
  const locales = await LangsysApp.getLocales(); // { LanguageName: [{ code, name }] }
207
- const localeName = await LangsysApp.getLocaleNameWithLookup('es-es', true, 'fr-fr'); // 'espagnol'
207
+ const localeName = await LangsysApp.getLocaleNameWithLookup('es-ES', true, 'fr-FR'); // 'espagnol'
208
208
  ```
209
209
 
210
210
  ## The `Signal<T>` primitive
package/dist/index.d.mts CHANGED
@@ -358,8 +358,12 @@ declare class LangsysAppClass {
358
358
  private getUserLanguagePreferences;
359
359
  private parseAcceptLanguageHeader;
360
360
  private findBestLocaleMatch;
361
- private normalizeLocale;
362
- private getLanguageCode;
361
+ /**
362
+ * Resolve the effective locale for a data-fetching helper: explicit
363
+ * argument, else the user's current locale, else the base locale —
364
+ * always in BCP 47 canonical form so cache keys and API routes agree.
365
+ */
366
+ private resolveLocale;
363
367
  }
364
368
  declare const LangsysApp: LangsysAppClass;
365
369
 
@@ -648,6 +652,8 @@ declare function md5(inputString: string): string;
648
652
  *
649
653
  * - Matches `{var, plural, …}`, `{var, select, …}`, `{var, selectordinal, …}`,
650
654
  * `{var, number, …}`, `{var, date, …}`, `{var, time, …}`.
655
+ * - Also matches the style-less forms `{var, number}`, `{var, date}`,
656
+ * `{var, time}` — valid ICU with the locale-default style.
651
657
  * - Doesn't match plain interpolation slots like `{name}` or `{count}`.
652
658
  *
653
659
  * Exported because the Translation Manager UI needs the same detection logic
@@ -664,7 +670,11 @@ declare function isICU(template: string): boolean;
664
670
  * etc.) and gender-select branches.
665
671
  * - Simple `{name}` interpolation: cheap regex replacement. Unknown
666
672
  * placeholders are left untouched so missing data is visible to the
667
- * developer rather than silently rendering empty strings.
673
+ * developer rather than silently rendering empty strings. Number and
674
+ * Date values are formatted per the target locale's CLDR rules
675
+ * (`Intl.NumberFormat` / `Intl.DateTimeFormat`) — pass numbers as
676
+ * strings to opt out (e.g. IDs and codes that must not get grouping
677
+ * separators).
668
678
  *
669
679
  * Malformed ICU falls through to simple interpolation rather than throwing,
670
680
  * so the developer sees a broken-but-visible string instead of a runtime crash.
@@ -676,6 +686,28 @@ declare function isICU(template: string): boolean;
676
686
  */
677
687
  declare function interpolate(template: string, params: Record<string, unknown>, locale?: string): string;
678
688
 
689
+ /**
690
+ * Locale-identifier handling, CLDR/BCP 47 flavored. Everything here rides on
691
+ * the platform's native `Intl` — no bundled locale data.
692
+ *
693
+ * The SDK's convention: canonicalize once at every input boundary (init
694
+ * config, user-locale store emissions, detectPreferredLocale, API routes) and
695
+ * treat the canonical string as the identity everywhere downstream — cache
696
+ * keys, equality checks, and the wire format all use it.
697
+ */
698
+ /**
699
+ * Canonicalize a locale identifier to BCP 47 canonical form (the form CLDR
700
+ * data is keyed by): language lowercase, script Titlecase, region uppercase —
701
+ * `en-us` → `en-US`, `zh-hant-tw` → `zh-Hant-TW`. Underscores are accepted
702
+ * and converted (`en_US` → `en-US`). Legacy aliases are resolved (`iw` → `he`,
703
+ * `in` → `id`).
704
+ *
705
+ * Structurally invalid tags fall back to best-effort manual casing rather
706
+ * than throwing, so a bad config value degrades to a failed catalog lookup
707
+ * instead of a crash.
708
+ */
709
+ declare function canonicalizeLocale(locale: string): string;
710
+
679
711
  interface iProject {
680
712
  id: string;
681
713
  owner_id: string;
@@ -696,4 +728,4 @@ interface iProject {
696
728
  declare const t: TFunction;
697
729
  declare const tSignal: Signal<TFunction>;
698
730
 
699
- export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, createSignal, currentlyLoadedLocale, encodeRichText, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
731
+ export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
package/dist/index.d.ts CHANGED
@@ -358,8 +358,12 @@ declare class LangsysAppClass {
358
358
  private getUserLanguagePreferences;
359
359
  private parseAcceptLanguageHeader;
360
360
  private findBestLocaleMatch;
361
- private normalizeLocale;
362
- private getLanguageCode;
361
+ /**
362
+ * Resolve the effective locale for a data-fetching helper: explicit
363
+ * argument, else the user's current locale, else the base locale —
364
+ * always in BCP 47 canonical form so cache keys and API routes agree.
365
+ */
366
+ private resolveLocale;
363
367
  }
364
368
  declare const LangsysApp: LangsysAppClass;
365
369
 
@@ -648,6 +652,8 @@ declare function md5(inputString: string): string;
648
652
  *
649
653
  * - Matches `{var, plural, …}`, `{var, select, …}`, `{var, selectordinal, …}`,
650
654
  * `{var, number, …}`, `{var, date, …}`, `{var, time, …}`.
655
+ * - Also matches the style-less forms `{var, number}`, `{var, date}`,
656
+ * `{var, time}` — valid ICU with the locale-default style.
651
657
  * - Doesn't match plain interpolation slots like `{name}` or `{count}`.
652
658
  *
653
659
  * Exported because the Translation Manager UI needs the same detection logic
@@ -664,7 +670,11 @@ declare function isICU(template: string): boolean;
664
670
  * etc.) and gender-select branches.
665
671
  * - Simple `{name}` interpolation: cheap regex replacement. Unknown
666
672
  * placeholders are left untouched so missing data is visible to the
667
- * developer rather than silently rendering empty strings.
673
+ * developer rather than silently rendering empty strings. Number and
674
+ * Date values are formatted per the target locale's CLDR rules
675
+ * (`Intl.NumberFormat` / `Intl.DateTimeFormat`) — pass numbers as
676
+ * strings to opt out (e.g. IDs and codes that must not get grouping
677
+ * separators).
668
678
  *
669
679
  * Malformed ICU falls through to simple interpolation rather than throwing,
670
680
  * so the developer sees a broken-but-visible string instead of a runtime crash.
@@ -676,6 +686,28 @@ declare function isICU(template: string): boolean;
676
686
  */
677
687
  declare function interpolate(template: string, params: Record<string, unknown>, locale?: string): string;
678
688
 
689
+ /**
690
+ * Locale-identifier handling, CLDR/BCP 47 flavored. Everything here rides on
691
+ * the platform's native `Intl` — no bundled locale data.
692
+ *
693
+ * The SDK's convention: canonicalize once at every input boundary (init
694
+ * config, user-locale store emissions, detectPreferredLocale, API routes) and
695
+ * treat the canonical string as the identity everywhere downstream — cache
696
+ * keys, equality checks, and the wire format all use it.
697
+ */
698
+ /**
699
+ * Canonicalize a locale identifier to BCP 47 canonical form (the form CLDR
700
+ * data is keyed by): language lowercase, script Titlecase, region uppercase —
701
+ * `en-us` → `en-US`, `zh-hant-tw` → `zh-Hant-TW`. Underscores are accepted
702
+ * and converted (`en_US` → `en-US`). Legacy aliases are resolved (`iw` → `he`,
703
+ * `in` → `id`).
704
+ *
705
+ * Structurally invalid tags fall back to best-effort manual casing rather
706
+ * than throwing, so a bad config value degrades to a failed catalog lookup
707
+ * instead of a crash.
708
+ */
709
+ declare function canonicalizeLocale(locale: string): string;
710
+
679
711
  interface iProject {
680
712
  id: string;
681
713
  owner_id: string;
@@ -696,4 +728,4 @@ interface iProject {
696
728
  declare const t: TFunction;
697
729
  declare const tSignal: Signal<TFunction>;
698
730
 
699
- export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, createSignal, currentlyLoadedLocale, encodeRichText, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
731
+ export { type EncodedRichText, type ExtractParamKeys, LangsysApp, LangsysAppAPI, type LocaleSource, Logger, PHRASE_MARKER_ATTR, type ParamPrimitive, type ParamsFor, Phrase, type PhraseOptions, type RichSlot, type Signal, type Subscriber, type TArgs, type TFunction, Translate, type TranslateOptions, type TranslationParams, Translations, type Unsubscriber, type Updater, canonicalizeLocale, createSignal, currentlyLoadedLocale, encodeRichText, generateCustomId, getValue, type iCategories, type iContentBlock, type iCountry, type iCountryDialCode, type iCountryList, type iCurrency, type iCurrencyList, type iLangsysConfig, type iLangsysInitConfig, type ResponseObject as iLangsysResponse, type iLanguageName, type iLocaleData, type iLocaleDefault, type iLocaleFlat, type iProject, type iTranslations, interpolate, isContentBlockKnown, isEmpty, isICU, logger, markupTokenValues, md5, persist, reconstitute, registerContentBlock, sTranslations, stripSentinels, t, tSignal, tokenizeElement };
package/dist/index.js CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  var intlMessageformat = require('intl-messageformat');
4
4
 
5
+ // src/locale.ts
6
+ function canonicalizeLocale(locale) {
7
+ if (!locale || typeof locale !== "string") return locale;
8
+ const cleaned = locale.trim().replace(/_/g, "-");
9
+ try {
10
+ const [canonical] = Intl.getCanonicalLocales(cleaned);
11
+ return canonical ?? cleaned;
12
+ } catch {
13
+ return cleaned.split("-").map((part, i) => {
14
+ if (i === 0) return part.toLowerCase();
15
+ if (part.length === 4) return part[0].toUpperCase() + part.slice(1).toLowerCase();
16
+ if (part.length === 2 || /^\d{3}$/.test(part)) return part.toUpperCase();
17
+ return part.toLowerCase();
18
+ }).join("-");
19
+ }
20
+ }
21
+ function maximizedLangScript(locale) {
22
+ try {
23
+ const max = new Intl.Locale(canonicalizeLocale(locale)).maximize();
24
+ return `${max.language}-${max.script ?? ""}`;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
5
30
  // src/signal.ts
6
31
  function createSignal(initial) {
7
32
  let value = initial;
@@ -93,7 +118,7 @@ var LangsysAppAPIClass = class {
93
118
  return await this.get("authorize-project/[projectid]");
94
119
  }
95
120
  async getTranslations(locale) {
96
- return await this.get("translations", { project_id: this.config.projectid, locale });
121
+ return await this.get("translations", { project_id: this.config.projectid, locale: canonicalizeLocale(locale) });
97
122
  }
98
123
  /**
99
124
  * Register phrases and/or content blocks via the current unified endpoint.
@@ -239,24 +264,37 @@ var config = {
239
264
  function isICU(template) {
240
265
  return ICU_PATTERN.test(template);
241
266
  }
242
- var ICU_PATTERN = /\{[^{}]+,\s*(plural|select|selectordinal|number|date|time)\s*,/;
267
+ var ICU_PATTERN = /\{[^{}]+,\s*(plural|select|selectordinal|number|date|time)\s*[,}]/;
243
268
  function interpolate(template, params, locale) {
244
269
  if (isICU(template)) {
245
270
  try {
246
- return new intlMessageformat.IntlMessageFormat(template, locale ?? "en").format(params);
271
+ return new intlMessageformat.IntlMessageFormat(template, locale || "en").format(params);
247
272
  } catch {
248
- return simpleInterpolate(template, params);
273
+ return simpleInterpolate(template, params, locale);
249
274
  }
250
275
  }
251
- return simpleInterpolate(template, params);
276
+ return simpleInterpolate(template, params, locale);
252
277
  }
253
- function simpleInterpolate(template, params) {
278
+ function simpleInterpolate(template, params, locale) {
254
279
  return template.replace(/\{([^{},]+)\}/g, (match, rawKey) => {
255
280
  const key = rawKey.trim();
256
281
  if (!(key in params)) return match;
257
282
  const value = params[key];
258
283
  if (value === void 0 || value === null) return match;
259
- if (value instanceof Date) return value.toISOString();
284
+ if (value instanceof Date) {
285
+ try {
286
+ return new Intl.DateTimeFormat(locale || "en", { dateStyle: "medium" }).format(value);
287
+ } catch {
288
+ return value.toISOString();
289
+ }
290
+ }
291
+ if (typeof value === "number" || typeof value === "bigint") {
292
+ try {
293
+ return new Intl.NumberFormat(locale || "en").format(value);
294
+ } catch {
295
+ return String(value);
296
+ }
297
+ }
260
298
  return String(value);
261
299
  });
262
300
  }
@@ -282,7 +320,7 @@ var Translations = class {
282
320
  });
283
321
  this.config = config2;
284
322
  this.debug = new Logger(!!config2.debug);
285
- this.locale = config2.baseLocale || "";
323
+ this.locale = canonicalizeLocale(config2.baseLocale || "");
286
324
  this.tSignal = createSignal(this.buildTFn());
287
325
  sTranslations.subscribe(() => this.tSignal.set(this.buildTFn()));
288
326
  currentlyLoadedLocale.subscribe(() => this.tSignal.set(this.buildTFn()));
@@ -423,7 +461,7 @@ var Translations = class {
423
461
  this.config = config2;
424
462
  if (!this.config.projectid || !this.config.key) return;
425
463
  if (this.config.debug) this.debug.debugEnabled = this.config.debug;
426
- if (config2.baseLocale) this.locale = config2.baseLocale;
464
+ if (config2.baseLocale) this.locale = canonicalizeLocale(config2.baseLocale);
427
465
  this.debug.log("TRANSLATION SETUP INITIATED", this.config);
428
466
  if (typeof window !== "undefined") {
429
467
  if (this.isFirstClientRun && this.missingTokens.length > 0) {
@@ -451,6 +489,7 @@ var Translations = class {
451
489
  */
452
490
  markLoaded(locale) {
453
491
  if (!locale) return;
492
+ locale = canonicalizeLocale(locale);
454
493
  this.locale = locale;
455
494
  this.lastLoaded[locale] = (/* @__PURE__ */ new Date()).getTime() / 1e3;
456
495
  this.readyResolve();
@@ -462,6 +501,7 @@ var Translations = class {
462
501
  async change(locale, force = false, skipFetch = false) {
463
502
  if (!locale) return false;
464
503
  if (!this.config.projectid || !this.config.key) return false;
504
+ locale = canonicalizeLocale(locale);
465
505
  if (skipFetch) {
466
506
  this.debug.log("Using pre-fetched translations for locale", locale);
467
507
  this.locale = locale;
@@ -630,11 +670,11 @@ var LangsysAppClass = class {
630
670
  }
631
671
  if (!UserLocaleStore?.subscribe || typeof UserLocaleStore.get !== "function") {
632
672
  this.debug.error(
633
- "LangsysApp.init missing UserLocaleStore \u2014 pass any object satisfying LocaleSource (get + subscribe). createSignal('en-us') works if you have nothing of your own."
673
+ "LangsysApp.init missing UserLocaleStore \u2014 pass any object satisfying LocaleSource (get + subscribe). createSignal('en-US') works if you have nothing of your own."
634
674
  );
635
675
  return { status: false, errors: ["Missing UserLocaleStore"] };
636
676
  }
637
- baseLocale = baseLocale.toLowerCase();
677
+ baseLocale = canonicalizeLocale(baseLocale);
638
678
  if (!emulateFailureToLoad) {
639
679
  this.config = {
640
680
  projectid,
@@ -673,8 +713,9 @@ var LangsysAppClass = class {
673
713
  if (initialTranslations2 && initialTranslationsLocale) {
674
714
  this.Translations.markLoaded(initialTranslationsLocale);
675
715
  }
676
- this.config.sUserLocale.subscribe((locale) => {
716
+ this.config.sUserLocale.subscribe((rawLocale) => {
677
717
  if (!validateResponse.status) return;
718
+ const locale = canonicalizeLocale(rawLocale);
678
719
  this.getLocalesData(locale);
679
720
  this.debug.log("SUBSCRIBING TO USER LOCALE STORE");
680
721
  this.translationsLoadingPromise = this.Translations.change(locale, false);
@@ -682,7 +723,7 @@ var LangsysAppClass = class {
682
723
  return validateResponse;
683
724
  }
684
725
  async getDialCodes(inLocale) {
685
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
726
+ const locale = this.resolveLocale(inLocale);
686
727
  if (this.dialCodes.length && this.dialCodesLocale === locale) return this.dialCodes;
687
728
  const route = `countries/dial-codes/${locale}`;
688
729
  const response = await LangsysAppAPI.get(route);
@@ -695,7 +736,7 @@ var LangsysAppClass = class {
695
736
  return this.dialCodes;
696
737
  }
697
738
  async getCurrencies(inLocale) {
698
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
739
+ const locale = this.resolveLocale(inLocale);
699
740
  if (this.currencies.length && this.currenciesLocale === locale) return this.currencies;
700
741
  const route = `currencies/${locale}`;
701
742
  const response = await LangsysAppAPI.get(route);
@@ -708,7 +749,7 @@ var LangsysAppClass = class {
708
749
  return this.currencies;
709
750
  }
710
751
  async getCountries(inLocale) {
711
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
752
+ const locale = this.resolveLocale(inLocale);
712
753
  if (this.countries.length && this.countriesLocale === locale) return this.countries;
713
754
  const route = `countries/${locale}`;
714
755
  const response = await LangsysAppAPI.get(route);
@@ -722,7 +763,7 @@ var LangsysAppClass = class {
722
763
  }
723
764
  async getCountryName(forCountryCode, inLocale) {
724
765
  if (!forCountryCode) return "";
725
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
766
+ const locale = this.resolveLocale(inLocale);
726
767
  if (!this.countries.length || this.countriesLocale !== locale) await this.getCountries(inLocale);
727
768
  const country = this.countries.find((c) => c.code.toLowerCase() === forCountryCode.toLowerCase())?.label;
728
769
  if (!country) this.debug.warn("getCountryName failed to match", forCountryCode);
@@ -730,14 +771,14 @@ var LangsysAppClass = class {
730
771
  }
731
772
  async getCurrencyName(forCurrencyCode, inLocale) {
732
773
  if (!forCurrencyCode) return "";
733
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
774
+ const locale = this.resolveLocale(inLocale);
734
775
  if (!this.currencies.length || this.currenciesLocale !== locale) await this.getCurrencies(inLocale);
735
776
  const currency = this.currencies.find((c) => c.code?.toLowerCase() === forCurrencyCode.toLowerCase())?.name;
736
777
  if (!currency) this.debug.warn("getCurrencyName failed to match", forCurrencyCode);
737
778
  return currency || forCurrencyCode;
738
779
  }
739
780
  async getLocalesFormat(format = "", inLocale) {
740
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
781
+ const locale = this.resolveLocale(inLocale);
741
782
  const route = `locales/${locale}` + (format ? `/${format}` : "");
742
783
  const response = await LangsysAppAPI.get(route);
743
784
  if (response.errors || !response.status) {
@@ -758,7 +799,7 @@ var LangsysAppClass = class {
758
799
  return await this.getLocalesFormat("flat", inLocale);
759
800
  }
760
801
  async getLocalesData(inLocale, forceRefresh = false) {
761
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
802
+ const locale = this.resolveLocale(inLocale);
762
803
  if (this.locales?.[locale]?.length && !forceRefresh) return this.locales[locale];
763
804
  this.locales[locale] = await this.getLocalesFormat("data", inLocale);
764
805
  return this.locales[locale];
@@ -768,15 +809,16 @@ var LangsysAppClass = class {
768
809
  }
769
810
  async getLocaleNameWithLookup(forLocale, shortName = false, inLocale) {
770
811
  if (!forLocale) return "";
771
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
812
+ const locale = this.resolveLocale(inLocale);
772
813
  await this.getLocalesData(locale);
773
814
  return this.getLocaleName(forLocale, shortName, locale);
774
815
  }
775
816
  getLocaleName(forLocale, shortName = false, inLocale) {
776
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
817
+ const locale = this.resolveLocale(inLocale);
818
+ const target = canonicalizeLocale(forLocale).toLowerCase();
777
819
  let name = "";
778
820
  this.locales[locale]?.every((loc) => {
779
- if (loc.code === forLocale) {
821
+ if (loc.code.toLowerCase() === target) {
780
822
  name = shortName ? loc.lang_name : loc.locale_name;
781
823
  return false;
782
824
  }
@@ -802,7 +844,7 @@ var LangsysAppClass = class {
802
844
  const bestMatch = this.findBestLocaleMatch(userLocales, supportedLocales);
803
845
  if (bestMatch) return bestMatch;
804
846
  }
805
- return this.normalizeLocale(userLocales[0]);
847
+ return canonicalizeLocale(userLocales[0]);
806
848
  }
807
849
  getUserLanguagePreferences(acceptLanguageHeader) {
808
850
  if (acceptLanguageHeader) {
@@ -839,30 +881,26 @@ var LangsysAppClass = class {
839
881
  }
840
882
  findBestLocaleMatch(userLocales, supportedLocales) {
841
883
  if (!userLocales.length || !supportedLocales.length) return null;
842
- const normalizedSupported = supportedLocales.map((l) => this.normalizeLocale(l));
884
+ const supported = supportedLocales.map((l) => canonicalizeLocale(l));
843
885
  for (const userLocale of userLocales) {
844
- const normalizedUser = this.normalizeLocale(userLocale);
845
- if (normalizedSupported.includes(normalizedUser)) return normalizedUser;
886
+ const user = canonicalizeLocale(userLocale);
887
+ if (supported.includes(user)) return user;
846
888
  }
847
889
  for (const userLocale of userLocales) {
848
- const userLang = this.getLanguageCode(userLocale);
849
- for (const supported of normalizedSupported) {
850
- if (this.getLanguageCode(supported) === userLang) return supported;
851
- }
890
+ const userKey = maximizedLangScript(userLocale);
891
+ if (!userKey) continue;
892
+ const match = supported.find((s) => maximizedLangScript(s) === userKey);
893
+ if (match) return match;
852
894
  }
853
895
  return null;
854
896
  }
855
- normalizeLocale(locale) {
856
- if (!locale || typeof locale !== "string") return locale;
857
- const parts = locale.split(/[-_]/);
858
- if (parts.length === 1) return parts[0].toLowerCase();
859
- const language = parts[0].toLowerCase();
860
- const region = parts[1].toUpperCase();
861
- return `${language}-${region}`;
862
- }
863
- getLanguageCode(locale) {
864
- if (!locale || typeof locale !== "string") return "";
865
- return locale.split(/[-_]/)[0].toLowerCase();
897
+ /**
898
+ * Resolve the effective locale for a data-fetching helper: explicit
899
+ * argument, else the user's current locale, else the base locale —
900
+ * always in BCP 47 canonical form so cache keys and API routes agree.
901
+ */
902
+ resolveLocale(inLocale) {
903
+ return canonicalizeLocale(inLocale || this.config.sUserLocale.get() || this.config.baseLocale);
866
904
  }
867
905
  };
868
906
  var LangsysApp = new LangsysAppClass();
@@ -1495,6 +1533,7 @@ exports.PHRASE_MARKER_ATTR = PHRASE_MARKER_ATTR;
1495
1533
  exports.Phrase = Phrase;
1496
1534
  exports.Translate = Translate;
1497
1535
  exports.Translations = Translations;
1536
+ exports.canonicalizeLocale = canonicalizeLocale;
1498
1537
  exports.createSignal = createSignal;
1499
1538
  exports.currentlyLoadedLocale = currentlyLoadedLocale;
1500
1539
  exports.encodeRichText = encodeRichText;