langsys-js-typescript 0.2.2 → 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,8 +489,10 @@ 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;
495
+ this.readyResolve();
456
496
  }
457
497
  /**
458
498
  * React to a user-locale change. Fetches translations if we haven't
@@ -461,10 +501,12 @@ var Translations = class {
461
501
  async change(locale, force = false, skipFetch = false) {
462
502
  if (!locale) return false;
463
503
  if (!this.config.projectid || !this.config.key) return false;
504
+ locale = canonicalizeLocale(locale);
464
505
  if (skipFetch) {
465
506
  this.debug.log("Using pre-fetched translations for locale", locale);
466
507
  this.locale = locale;
467
508
  this.lastLoaded[locale] = (/* @__PURE__ */ new Date()).getTime() / 1e3;
509
+ this.readyResolve();
468
510
  return true;
469
511
  }
470
512
  const now = (/* @__PURE__ */ new Date()).getTime() / 1e3;
@@ -628,11 +670,11 @@ var LangsysAppClass = class {
628
670
  }
629
671
  if (!UserLocaleStore?.subscribe || typeof UserLocaleStore.get !== "function") {
630
672
  this.debug.error(
631
- "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."
632
674
  );
633
675
  return { status: false, errors: ["Missing UserLocaleStore"] };
634
676
  }
635
- baseLocale = baseLocale.toLowerCase();
677
+ baseLocale = canonicalizeLocale(baseLocale);
636
678
  if (!emulateFailureToLoad) {
637
679
  this.config = {
638
680
  projectid,
@@ -671,8 +713,9 @@ var LangsysAppClass = class {
671
713
  if (initialTranslations2 && initialTranslationsLocale) {
672
714
  this.Translations.markLoaded(initialTranslationsLocale);
673
715
  }
674
- this.config.sUserLocale.subscribe((locale) => {
716
+ this.config.sUserLocale.subscribe((rawLocale) => {
675
717
  if (!validateResponse.status) return;
718
+ const locale = canonicalizeLocale(rawLocale);
676
719
  this.getLocalesData(locale);
677
720
  this.debug.log("SUBSCRIBING TO USER LOCALE STORE");
678
721
  this.translationsLoadingPromise = this.Translations.change(locale, false);
@@ -680,7 +723,7 @@ var LangsysAppClass = class {
680
723
  return validateResponse;
681
724
  }
682
725
  async getDialCodes(inLocale) {
683
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
726
+ const locale = this.resolveLocale(inLocale);
684
727
  if (this.dialCodes.length && this.dialCodesLocale === locale) return this.dialCodes;
685
728
  const route = `countries/dial-codes/${locale}`;
686
729
  const response = await LangsysAppAPI.get(route);
@@ -693,7 +736,7 @@ var LangsysAppClass = class {
693
736
  return this.dialCodes;
694
737
  }
695
738
  async getCurrencies(inLocale) {
696
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
739
+ const locale = this.resolveLocale(inLocale);
697
740
  if (this.currencies.length && this.currenciesLocale === locale) return this.currencies;
698
741
  const route = `currencies/${locale}`;
699
742
  const response = await LangsysAppAPI.get(route);
@@ -706,7 +749,7 @@ var LangsysAppClass = class {
706
749
  return this.currencies;
707
750
  }
708
751
  async getCountries(inLocale) {
709
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
752
+ const locale = this.resolveLocale(inLocale);
710
753
  if (this.countries.length && this.countriesLocale === locale) return this.countries;
711
754
  const route = `countries/${locale}`;
712
755
  const response = await LangsysAppAPI.get(route);
@@ -720,7 +763,7 @@ var LangsysAppClass = class {
720
763
  }
721
764
  async getCountryName(forCountryCode, inLocale) {
722
765
  if (!forCountryCode) return "";
723
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
766
+ const locale = this.resolveLocale(inLocale);
724
767
  if (!this.countries.length || this.countriesLocale !== locale) await this.getCountries(inLocale);
725
768
  const country = this.countries.find((c) => c.code.toLowerCase() === forCountryCode.toLowerCase())?.label;
726
769
  if (!country) this.debug.warn("getCountryName failed to match", forCountryCode);
@@ -728,14 +771,14 @@ var LangsysAppClass = class {
728
771
  }
729
772
  async getCurrencyName(forCurrencyCode, inLocale) {
730
773
  if (!forCurrencyCode) return "";
731
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
774
+ const locale = this.resolveLocale(inLocale);
732
775
  if (!this.currencies.length || this.currenciesLocale !== locale) await this.getCurrencies(inLocale);
733
776
  const currency = this.currencies.find((c) => c.code?.toLowerCase() === forCurrencyCode.toLowerCase())?.name;
734
777
  if (!currency) this.debug.warn("getCurrencyName failed to match", forCurrencyCode);
735
778
  return currency || forCurrencyCode;
736
779
  }
737
780
  async getLocalesFormat(format = "", inLocale) {
738
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
781
+ const locale = this.resolveLocale(inLocale);
739
782
  const route = `locales/${locale}` + (format ? `/${format}` : "");
740
783
  const response = await LangsysAppAPI.get(route);
741
784
  if (response.errors || !response.status) {
@@ -756,7 +799,7 @@ var LangsysAppClass = class {
756
799
  return await this.getLocalesFormat("flat", inLocale);
757
800
  }
758
801
  async getLocalesData(inLocale, forceRefresh = false) {
759
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
802
+ const locale = this.resolveLocale(inLocale);
760
803
  if (this.locales?.[locale]?.length && !forceRefresh) return this.locales[locale];
761
804
  this.locales[locale] = await this.getLocalesFormat("data", inLocale);
762
805
  return this.locales[locale];
@@ -766,15 +809,16 @@ var LangsysAppClass = class {
766
809
  }
767
810
  async getLocaleNameWithLookup(forLocale, shortName = false, inLocale) {
768
811
  if (!forLocale) return "";
769
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
812
+ const locale = this.resolveLocale(inLocale);
770
813
  await this.getLocalesData(locale);
771
814
  return this.getLocaleName(forLocale, shortName, locale);
772
815
  }
773
816
  getLocaleName(forLocale, shortName = false, inLocale) {
774
- const locale = inLocale || this.config.sUserLocale.get() || this.config.baseLocale;
817
+ const locale = this.resolveLocale(inLocale);
818
+ const target = canonicalizeLocale(forLocale).toLowerCase();
775
819
  let name = "";
776
820
  this.locales[locale]?.every((loc) => {
777
- if (loc.code === forLocale) {
821
+ if (loc.code.toLowerCase() === target) {
778
822
  name = shortName ? loc.lang_name : loc.locale_name;
779
823
  return false;
780
824
  }
@@ -800,7 +844,7 @@ var LangsysAppClass = class {
800
844
  const bestMatch = this.findBestLocaleMatch(userLocales, supportedLocales);
801
845
  if (bestMatch) return bestMatch;
802
846
  }
803
- return this.normalizeLocale(userLocales[0]);
847
+ return canonicalizeLocale(userLocales[0]);
804
848
  }
805
849
  getUserLanguagePreferences(acceptLanguageHeader) {
806
850
  if (acceptLanguageHeader) {
@@ -837,30 +881,26 @@ var LangsysAppClass = class {
837
881
  }
838
882
  findBestLocaleMatch(userLocales, supportedLocales) {
839
883
  if (!userLocales.length || !supportedLocales.length) return null;
840
- const normalizedSupported = supportedLocales.map((l) => this.normalizeLocale(l));
884
+ const supported = supportedLocales.map((l) => canonicalizeLocale(l));
841
885
  for (const userLocale of userLocales) {
842
- const normalizedUser = this.normalizeLocale(userLocale);
843
- if (normalizedSupported.includes(normalizedUser)) return normalizedUser;
886
+ const user = canonicalizeLocale(userLocale);
887
+ if (supported.includes(user)) return user;
844
888
  }
845
889
  for (const userLocale of userLocales) {
846
- const userLang = this.getLanguageCode(userLocale);
847
- for (const supported of normalizedSupported) {
848
- if (this.getLanguageCode(supported) === userLang) return supported;
849
- }
890
+ const userKey = maximizedLangScript(userLocale);
891
+ if (!userKey) continue;
892
+ const match = supported.find((s) => maximizedLangScript(s) === userKey);
893
+ if (match) return match;
850
894
  }
851
895
  return null;
852
896
  }
853
- normalizeLocale(locale) {
854
- if (!locale || typeof locale !== "string") return locale;
855
- const parts = locale.split(/[-_]/);
856
- if (parts.length === 1) return parts[0].toLowerCase();
857
- const language = parts[0].toLowerCase();
858
- const region = parts[1].toUpperCase();
859
- return `${language}-${region}`;
860
- }
861
- getLanguageCode(locale) {
862
- if (!locale || typeof locale !== "string") return "";
863
- 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);
864
904
  }
865
905
  };
866
906
  var LangsysApp = new LangsysAppClass();
@@ -1493,6 +1533,7 @@ exports.PHRASE_MARKER_ATTR = PHRASE_MARKER_ATTR;
1493
1533
  exports.Phrase = Phrase;
1494
1534
  exports.Translate = Translate;
1495
1535
  exports.Translations = Translations;
1536
+ exports.canonicalizeLocale = canonicalizeLocale;
1496
1537
  exports.createSignal = createSignal;
1497
1538
  exports.currentlyLoadedLocale = currentlyLoadedLocale;
1498
1539
  exports.encodeRichText = encodeRichText;