@sonenta/react-i18next 2.2.0 → 2.3.1

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.1" : "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,73 @@ 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
+ * (`{apiBase}/cdn/v1/{project}/{version}/latest/languages.json`, public, no
779
+ * auth, CORS-open, CDN-cached `max-age=60` + ETag) — the SET of locales
780
+ * published for the active version (compute-on-serve from cdn_releases,
781
+ * always in sync with what is published). Codes only; enriched from the
782
+ * catalog. A 404/offline keeps any prior manifest (`availableLanguages` then
783
+ * falls back to default + fallback). Same route on prod + dev (backend task
784
+ * 989+; the authed `/v1/projects/{id}/languages` is ALL project languages,
785
+ * not the published set — this manifest is the in-production set).
786
+ */
787
+ async _loadManifest(fetchImpl, opts = {}) {
788
+ if (this._manifestDisabled) return;
789
+ const url = `${this._config.apiBase.replace(/\/+$/, "")}/cdn/v1/${this._config.projectUuid}/${this._config.version}/latest/languages.json`;
790
+ const init = { method: "GET", credentials: "omit" };
791
+ if (opts.bust) init.cache = "reload";
792
+ try {
793
+ const r = await fetchImpl(url, init);
794
+ if (!r.ok) return;
795
+ const data = await r.json();
796
+ const raw = Array.isArray(data.languages) ? data.languages : [];
797
+ const languages = [];
798
+ for (const e of raw) {
799
+ if (typeof e === "string") {
800
+ languages.push({ code: e });
801
+ } else if (e && typeof e === "object" && typeof e.code === "string") {
802
+ const obj = e;
803
+ languages.push({
804
+ code: obj.code,
805
+ published_at: typeof obj.published_at === "string" ? obj.published_at : void 0
806
+ });
807
+ }
808
+ }
809
+ this._manifest = {
810
+ default_locale: typeof data.default_locale === "string" ? data.default_locale : void 0,
811
+ languages
812
+ };
813
+ } catch {
814
+ }
815
+ }
816
+ /** Languages PUBLISHED for the active version (available-languages feature):
817
+ * the manifest's codes enriched with catalog metadata, marked with the
818
+ * default locale + any `published_at`. Falls back to the configured
819
+ * `defaultLocale` + `fallbackLng` when no manifest is available. */
820
+ get availableLanguages() {
821
+ const def = this._manifest?.default_locale ?? this._defaultLocale;
822
+ let entries = this._manifest?.languages;
823
+ if (!entries || entries.length === 0) {
824
+ const seen = /* @__PURE__ */ new Set();
825
+ entries = [];
826
+ for (const c of [this._defaultLocale, ...asArray(this.fallbackLng)]) {
827
+ if (c && !seen.has(c)) {
828
+ seen.add(c);
829
+ entries.push({ code: c });
830
+ }
831
+ }
832
+ }
833
+ return entries.map(({ code, published_at }) => {
834
+ const meta = this.languageMeta(code);
835
+ return {
836
+ ...meta ?? {},
837
+ code,
838
+ ...published_at ? { published_at } : {},
839
+ is_default: code === def
840
+ };
841
+ });
842
+ }
762
843
  async _loadCatalog(fetchImpl) {
763
844
  if (this._catalogDisabled) return;
764
845
  this._catalog.merge(await loadCatalog(this._config.apiBase, fetchImpl));
@@ -849,12 +930,14 @@ var SonentaI18n = class {
849
930
  if (opts.namespace && opts.namespace !== ns) continue;
850
931
  targets.push({ locale, ns });
851
932
  }
852
- if (targets.length === 0) return;
853
- await Promise.all(
854
- targets.map(
933
+ if (targets.length === 0 && (opts.locale || opts.namespace)) return;
934
+ const manifest = this._loadManifest(fetch, { bust: true });
935
+ await Promise.all([
936
+ manifest,
937
+ ...targets.map(
855
938
  (t2) => this._loadBundle(t2.locale, t2.ns, fetch, { bust: true })
856
939
  )
857
- );
940
+ ]);
858
941
  this._notify();
859
942
  };
860
943
  /**
@@ -1445,6 +1528,9 @@ function useTranslation(defaultNamespace) {
1445
1528
  }, []);
1446
1529
  return { t: t2, i18n: snapshot };
1447
1530
  }
1531
+ function useAvailableLanguages() {
1532
+ return useI18nSnapshot().availableLanguages;
1533
+ }
1448
1534
 
1449
1535
  // src/trans.tsx
1450
1536
  var import_react3 = require("react");
@@ -1499,6 +1585,7 @@ function splitOnComponents(text, components) {
1499
1585
  logTransport,
1500
1586
  surfaceForWidth,
1501
1587
  t,
1588
+ useAvailableLanguages,
1502
1589
  useTranslation
1503
1590
  });
1504
1591
  //# sourceMappingURL=index.cjs.map