@sonenta/react-i18next 2.2.0 → 2.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/README.md CHANGED
@@ -540,6 +540,41 @@ Resolution is locale-outer / surface-inner (same chain as `t()`): `(fr-CA,
540
540
  aria_label) > (fr-CA, base) > (fr, aria_label) > (fr, base)`. Overlays are
541
541
  CDN-only (`env: "dev"` is base-only).
542
542
 
543
+ ## Available languages (runtime switcher)
544
+
545
+ List the languages **published for the active version** at runtime — so adding
546
+ a language in Sonenta makes it appear in your switcher **without recompiling
547
+ the app**. The set comes from a per-version CDN manifest (public, cacheable,
548
+ no auth); each code is enriched from the language catalog (`native_name`,
549
+ `rtl`, …).
550
+
551
+ ```tsx
552
+ import { useTranslation, useAvailableLanguages } from "@sonenta/react-i18next";
553
+
554
+ function LanguageSwitcher() {
555
+ const { i18n } = useTranslation();
556
+ const languages = useAvailableLanguages(); // [{ code, native_name, rtl, is_default, published_at? }]
557
+ return (
558
+ <select value={i18n.language} onChange={(e) => i18n.setLocale(e.target.value)}>
559
+ {languages.map((l) => (
560
+ <option key={l.code} value={l.code}>
561
+ {l.native_name ?? l.code}
562
+ </option>
563
+ ))}
564
+ </select>
565
+ );
566
+ }
567
+ ```
568
+
569
+ - A newly-published language appears on the **next load** (the manifest is
570
+ CDN-served), or immediately after `i18n.reload()` — no rebuild, no redeploy
571
+ of your app.
572
+ - `is_default` marks the project's default locale; `published_at` (when the
573
+ manifest provides it) lets you flag recently-added languages.
574
+ - Falls back to `defaultLocale` + `fallbackLng` when no manifest is available
575
+ (or `env: "dev"`, which is CDN-only). Opt out with `disableLanguageManifest`.
576
+ - Also imperative: `i18n.availableLanguages`.
577
+
543
578
  ## Recipes
544
579
 
545
580
  ### Next.js (App Router)
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  logTransport: () => logTransport,
33
33
  surfaceForWidth: () => surfaceForWidth,
34
34
  t: () => t,
35
+ useAvailableLanguages: () => useAvailableLanguages,
35
36
  useTranslation: () => useTranslation
36
37
  });
37
38
  module.exports = __toCommonJS(index_exports);
@@ -237,7 +238,7 @@ function flattenPlurals(tree, locale) {
237
238
 
238
239
  // src/transport.ts
239
240
  var SDK_LIB = "@sonenta/react-i18next";
240
- var SDK_VER = true ? "2.2.0" : "0.0.0-dev";
241
+ var SDK_VER = true ? "2.3.0" : "0.0.0-dev";
241
242
  function defaultTransport(opts) {
242
243
  return async (batch) => {
243
244
  if (!batch.length) return;
@@ -429,6 +430,14 @@ var SonentaI18n = class {
429
430
  // config.languageCatalog and/or the public GET /v1/languages in start().
430
431
  _catalog = new LanguageCatalog();
431
432
  _catalogDisabled = false;
433
+ // Published-languages manifest (available-languages feature). Per-version,
434
+ // public CDN, no auth: the SET of locales published for the active version.
435
+ // Codes are enriched from `_catalog` (#803) for native_name/rtl/etc.
436
+ _manifestDisabled = false;
437
+ _manifest = null;
438
+ // The configured default locale (the initial `this.locale`, which is mutable
439
+ // via setLocale) — used as the available-languages fallback + default marker.
440
+ _defaultLocale;
432
441
  _config;
433
442
  // Surface variant (#911). When set, each loaded (locale,ns) is composed as
434
443
  // base ⊕ sparse `{ns}.{surface}.json` overlay (overlay wins per key). We
@@ -476,6 +485,7 @@ var SonentaI18n = class {
476
485
  );
477
486
  }
478
487
  this.locale = config.defaultLocale;
488
+ this._defaultLocale = config.defaultLocale;
479
489
  this.fallbackLng = config.fallbackLng;
480
490
  this._surface = config.surface;
481
491
  for (const s of config.a11ySurfaces ?? []) this._a11ySurfaces.add(s);
@@ -578,6 +588,7 @@ var SonentaI18n = class {
578
588
  this._rebindFormat();
579
589
  this._catalogDisabled = config.disableLanguageCatalog === true;
580
590
  this._catalog.merge(config.languageCatalog);
591
+ this._manifestDisabled = config.disableLanguageManifest === true;
581
592
  const active = config.initialBundles?.[this.locale];
582
593
  if (active && this._config.namespaces.every(
583
594
  (n) => active[n] && Object.keys(active[n]).length > 0
@@ -674,6 +685,7 @@ var SonentaI18n = class {
674
685
  dir: this.dir,
675
686
  nativeName: this.nativeName,
676
687
  languageMeta: this.languageMeta,
688
+ availableLanguages: this.availableLanguages,
677
689
  i18next: this._i18next
678
690
  };
679
691
  }
@@ -735,7 +747,9 @@ var SonentaI18n = class {
735
747
  // Best-effort: align the key separator with the version's key_style (#754).
736
748
  this._loadKeyStyle(fetchImpl),
737
749
  // Best-effort: load the public language catalog for dir()/nativeName().
738
- this._loadCatalog(fetchImpl)
750
+ this._loadCatalog(fetchImpl),
751
+ // Best-effort: load the per-version published-languages manifest.
752
+ this._loadManifest(fetchImpl)
739
753
  ]);
740
754
  await this._syncLanguage();
741
755
  this.ready = true;
@@ -759,6 +773,71 @@ var SonentaI18n = class {
759
773
  }
760
774
  /** Best-effort: fetch the PUBLIC language catalog and merge it in (#803).
761
775
  * Skipped when disabled; a failure keeps any embedded catalog. */
776
+ /**
777
+ * Best-effort: fetch the per-version published-languages manifest
778
+ * (`{cdnBase}/p/{project}/{version}/latest/languages.json`, public, no auth,
779
+ * CDN-cached) — the SET of locales published for the active version. Codes
780
+ * only; enriched from the catalog. A 404/offline keeps any prior manifest
781
+ * (`availableLanguages` then falls back to default + fallback). CDN-only:
782
+ * `env: "dev"` skips it (the dev runtime has no manifest yet).
783
+ */
784
+ async _loadManifest(fetchImpl, opts = {}) {
785
+ if (this._manifestDisabled) return;
786
+ if (this._config.env === "dev") return;
787
+ const url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/languages.json`;
788
+ const init = { method: "GET", credentials: "omit" };
789
+ if (opts.bust) init.cache = "reload";
790
+ try {
791
+ const r = await fetchImpl(url, init);
792
+ if (!r.ok) return;
793
+ const data = await r.json();
794
+ const raw = Array.isArray(data.languages) ? data.languages : [];
795
+ const languages = [];
796
+ for (const e of raw) {
797
+ if (typeof e === "string") {
798
+ languages.push({ code: e });
799
+ } else if (e && typeof e === "object" && typeof e.code === "string") {
800
+ const obj = e;
801
+ languages.push({
802
+ code: obj.code,
803
+ published_at: typeof obj.published_at === "string" ? obj.published_at : void 0
804
+ });
805
+ }
806
+ }
807
+ this._manifest = {
808
+ default_locale: typeof data.default_locale === "string" ? data.default_locale : void 0,
809
+ languages
810
+ };
811
+ } catch {
812
+ }
813
+ }
814
+ /** Languages PUBLISHED for the active version (available-languages feature):
815
+ * the manifest's codes enriched with catalog metadata, marked with the
816
+ * default locale + any `published_at`. Falls back to the configured
817
+ * `defaultLocale` + `fallbackLng` when no manifest is available. */
818
+ get availableLanguages() {
819
+ const def = this._manifest?.default_locale ?? this._defaultLocale;
820
+ let entries = this._manifest?.languages;
821
+ if (!entries || entries.length === 0) {
822
+ const seen = /* @__PURE__ */ new Set();
823
+ entries = [];
824
+ for (const c of [this._defaultLocale, ...asArray(this.fallbackLng)]) {
825
+ if (c && !seen.has(c)) {
826
+ seen.add(c);
827
+ entries.push({ code: c });
828
+ }
829
+ }
830
+ }
831
+ return entries.map(({ code, published_at }) => {
832
+ const meta = this.languageMeta(code);
833
+ return {
834
+ ...meta ?? {},
835
+ code,
836
+ ...published_at ? { published_at } : {},
837
+ is_default: code === def
838
+ };
839
+ });
840
+ }
762
841
  async _loadCatalog(fetchImpl) {
763
842
  if (this._catalogDisabled) return;
764
843
  this._catalog.merge(await loadCatalog(this._config.apiBase, fetchImpl));
@@ -849,12 +928,14 @@ var SonentaI18n = class {
849
928
  if (opts.namespace && opts.namespace !== ns) continue;
850
929
  targets.push({ locale, ns });
851
930
  }
852
- if (targets.length === 0) return;
853
- await Promise.all(
854
- targets.map(
931
+ if (targets.length === 0 && (opts.locale || opts.namespace)) return;
932
+ const manifest = this._loadManifest(fetch, { bust: true });
933
+ await Promise.all([
934
+ manifest,
935
+ ...targets.map(
855
936
  (t2) => this._loadBundle(t2.locale, t2.ns, fetch, { bust: true })
856
937
  )
857
- );
938
+ ]);
858
939
  this._notify();
859
940
  };
860
941
  /**
@@ -1445,6 +1526,9 @@ function useTranslation(defaultNamespace) {
1445
1526
  }, []);
1446
1527
  return { t: t2, i18n: snapshot };
1447
1528
  }
1529
+ function useAvailableLanguages() {
1530
+ return useI18nSnapshot().availableLanguages;
1531
+ }
1448
1532
 
1449
1533
  // src/trans.tsx
1450
1534
  var import_react3 = require("react");
@@ -1499,6 +1583,7 @@ function splitOnComponents(text, components) {
1499
1583
  logTransport,
1500
1584
  surfaceForWidth,
1501
1585
  t,
1586
+ useAvailableLanguages,
1502
1587
  useTranslation
1503
1588
  });
1504
1589
  //# sourceMappingURL=index.cjs.map