@sonenta/react-i18next 2.1.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/dist/index.js CHANGED
@@ -210,7 +210,7 @@ function flattenPlurals(tree, locale) {
210
210
 
211
211
  // src/transport.ts
212
212
  var SDK_LIB = "@sonenta/react-i18next";
213
- var SDK_VER = true ? "2.1.0" : "0.0.0-dev";
213
+ var SDK_VER = true ? "2.3.0" : "0.0.0-dev";
214
214
  function defaultTransport(opts) {
215
215
  return async (batch) => {
216
216
  if (!batch.length) return;
@@ -402,6 +402,14 @@ var SonentaI18n = class {
402
402
  // config.languageCatalog and/or the public GET /v1/languages in start().
403
403
  _catalog = new LanguageCatalog();
404
404
  _catalogDisabled = false;
405
+ // Published-languages manifest (available-languages feature). Per-version,
406
+ // public CDN, no auth: the SET of locales published for the active version.
407
+ // Codes are enriched from `_catalog` (#803) for native_name/rtl/etc.
408
+ _manifestDisabled = false;
409
+ _manifest = null;
410
+ // The configured default locale (the initial `this.locale`, which is mutable
411
+ // via setLocale) — used as the available-languages fallback + default marker.
412
+ _defaultLocale;
405
413
  _config;
406
414
  // Surface variant (#911). When set, each loaded (locale,ns) is composed as
407
415
  // base ⊕ sparse `{ns}.{surface}.json` overlay (overlay wins per key). We
@@ -416,6 +424,14 @@ var SonentaI18n = class {
416
424
  // Resolved asset refs (#911 minimal v1), keyed `${locale}/${ns}/${keyPath}`
417
425
  // for the CURRENT composition (base assets, then overlay assets override).
418
426
  _assets = /* @__PURE__ */ new Map();
427
+ // A11y surfaces (#989 / task 994). Each configured a11y surface S is loaded
428
+ // as its OWN i18next namespace `${ns}__${S}` (sparse `{ns}.{S}.json`), so the
429
+ // accessors resolve through i18next's native locale-fallback + nested-key
430
+ // logic without touching the visible-text bundle. `_a11yAssets` carries the
431
+ // `alt_text` `$asset` refs, keyed `${locale}/${ns}/${keyPath}#${surface}`.
432
+ _a11ySurfaces = /* @__PURE__ */ new Set();
433
+ _plainLanguage = false;
434
+ _a11yAssets = /* @__PURE__ */ new Map();
419
435
  _missing;
420
436
  _listeners = /* @__PURE__ */ new Set();
421
437
  // Stable snapshot reference for useSyncExternalStore. Rebuilt ONLY in _notify
@@ -441,8 +457,12 @@ var SonentaI18n = class {
441
457
  );
442
458
  }
443
459
  this.locale = config.defaultLocale;
460
+ this._defaultLocale = config.defaultLocale;
444
461
  this.fallbackLng = config.fallbackLng;
445
462
  this._surface = config.surface;
463
+ for (const s of config.a11ySurfaces ?? []) this._a11ySurfaces.add(s);
464
+ this._plainLanguage = config.plainLanguage === true;
465
+ if (this._plainLanguage) this._a11ySurfaces.add("plain_language");
446
466
  let keySeparator = ".";
447
467
  if (config.keySeparator !== void 0) {
448
468
  keySeparator = config.keySeparator;
@@ -540,6 +560,7 @@ var SonentaI18n = class {
540
560
  this._rebindFormat();
541
561
  this._catalogDisabled = config.disableLanguageCatalog === true;
542
562
  this._catalog.merge(config.languageCatalog);
563
+ this._manifestDisabled = config.disableLanguageManifest === true;
543
564
  const active = config.initialBundles?.[this.locale];
544
565
  if (active && this._config.namespaces.every(
545
566
  (n) => active[n] && Object.keys(active[n]).length > 0
@@ -627,9 +648,16 @@ var SonentaI18n = class {
627
648
  surface: this._surface,
628
649
  setSurface: this.setSurface,
629
650
  asset: this.asset,
651
+ aria: this.aria,
652
+ alt: this.alt,
653
+ a11y: this.a11y,
654
+ a11yAsset: this.a11yAsset,
655
+ plainLanguage: this._plainLanguage,
656
+ setPlainLanguage: this.setPlainLanguage,
630
657
  dir: this.dir,
631
658
  nativeName: this.nativeName,
632
659
  languageMeta: this.languageMeta,
660
+ availableLanguages: this.availableLanguages,
633
661
  i18next: this._i18next
634
662
  };
635
663
  }
@@ -691,7 +719,9 @@ var SonentaI18n = class {
691
719
  // Best-effort: align the key separator with the version's key_style (#754).
692
720
  this._loadKeyStyle(fetchImpl),
693
721
  // Best-effort: load the public language catalog for dir()/nativeName().
694
- this._loadCatalog(fetchImpl)
722
+ this._loadCatalog(fetchImpl),
723
+ // Best-effort: load the per-version published-languages manifest.
724
+ this._loadManifest(fetchImpl)
695
725
  ]);
696
726
  await this._syncLanguage();
697
727
  this.ready = true;
@@ -715,6 +745,71 @@ var SonentaI18n = class {
715
745
  }
716
746
  /** Best-effort: fetch the PUBLIC language catalog and merge it in (#803).
717
747
  * Skipped when disabled; a failure keeps any embedded catalog. */
748
+ /**
749
+ * Best-effort: fetch the per-version published-languages manifest
750
+ * (`{cdnBase}/p/{project}/{version}/latest/languages.json`, public, no auth,
751
+ * CDN-cached) — the SET of locales published for the active version. Codes
752
+ * only; enriched from the catalog. A 404/offline keeps any prior manifest
753
+ * (`availableLanguages` then falls back to default + fallback). CDN-only:
754
+ * `env: "dev"` skips it (the dev runtime has no manifest yet).
755
+ */
756
+ async _loadManifest(fetchImpl, opts = {}) {
757
+ if (this._manifestDisabled) return;
758
+ if (this._config.env === "dev") return;
759
+ const url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/languages.json`;
760
+ const init = { method: "GET", credentials: "omit" };
761
+ if (opts.bust) init.cache = "reload";
762
+ try {
763
+ const r = await fetchImpl(url, init);
764
+ if (!r.ok) return;
765
+ const data = await r.json();
766
+ const raw = Array.isArray(data.languages) ? data.languages : [];
767
+ const languages = [];
768
+ for (const e of raw) {
769
+ if (typeof e === "string") {
770
+ languages.push({ code: e });
771
+ } else if (e && typeof e === "object" && typeof e.code === "string") {
772
+ const obj = e;
773
+ languages.push({
774
+ code: obj.code,
775
+ published_at: typeof obj.published_at === "string" ? obj.published_at : void 0
776
+ });
777
+ }
778
+ }
779
+ this._manifest = {
780
+ default_locale: typeof data.default_locale === "string" ? data.default_locale : void 0,
781
+ languages
782
+ };
783
+ } catch {
784
+ }
785
+ }
786
+ /** Languages PUBLISHED for the active version (available-languages feature):
787
+ * the manifest's codes enriched with catalog metadata, marked with the
788
+ * default locale + any `published_at`. Falls back to the configured
789
+ * `defaultLocale` + `fallbackLng` when no manifest is available. */
790
+ get availableLanguages() {
791
+ const def = this._manifest?.default_locale ?? this._defaultLocale;
792
+ let entries = this._manifest?.languages;
793
+ if (!entries || entries.length === 0) {
794
+ const seen = /* @__PURE__ */ new Set();
795
+ entries = [];
796
+ for (const c of [this._defaultLocale, ...asArray(this.fallbackLng)]) {
797
+ if (c && !seen.has(c)) {
798
+ seen.add(c);
799
+ entries.push({ code: c });
800
+ }
801
+ }
802
+ }
803
+ return entries.map(({ code, published_at }) => {
804
+ const meta = this.languageMeta(code);
805
+ return {
806
+ ...meta ?? {},
807
+ code,
808
+ ...published_at ? { published_at } : {},
809
+ is_default: code === def
810
+ };
811
+ });
812
+ }
718
813
  async _loadCatalog(fetchImpl) {
719
814
  if (this._catalogDisabled) return;
720
815
  this._catalog.merge(await loadCatalog(this._config.apiBase, fetchImpl));
@@ -805,12 +900,14 @@ var SonentaI18n = class {
805
900
  if (opts.namespace && opts.namespace !== ns) continue;
806
901
  targets.push({ locale, ns });
807
902
  }
808
- if (targets.length === 0) return;
809
- await Promise.all(
810
- targets.map(
903
+ if (targets.length === 0 && (opts.locale || opts.namespace)) return;
904
+ const manifest = this._loadManifest(fetch, { bust: true });
905
+ await Promise.all([
906
+ manifest,
907
+ ...targets.map(
811
908
  (t2) => this._loadBundle(t2.locale, t2.ns, fetch, { bust: true })
812
909
  )
813
- );
910
+ ]);
814
911
  this._notify();
815
912
  };
816
913
  /**
@@ -868,9 +965,96 @@ var SonentaI18n = class {
868
965
  }
869
966
  return void 0;
870
967
  };
968
+ // ---- A11y (#989 / task 994) ----
969
+ /** Split a possibly-`ns:key` string into `{ ns, bareKey }`, honoring the
970
+ * configured nsSeparator (mirrors {@link asset}). */
971
+ _splitKey(key, namespace) {
972
+ let ns = namespace ?? this.defaultNamespace;
973
+ let bareKey = key;
974
+ if (typeof this._nsSeparator === "string" && this._nsSeparator) {
975
+ const idx = key.indexOf(this._nsSeparator);
976
+ if (idx > 0) {
977
+ ns = key.slice(0, idx);
978
+ bareKey = key.slice(idx + this._nsSeparator.length);
979
+ }
980
+ }
981
+ return { ns, bareKey };
982
+ }
983
+ /** Resolve an a11y `surface` override for `key`, or `undefined` when none
984
+ * exists (the namespace is sparse). Goes through i18next so the locale
985
+ * fallback chain + nested keys + plurals apply. */
986
+ a11y = (key, surface, namespace) => {
987
+ if (!this._a11ySurfaces.has(surface)) return void 0;
988
+ const { ns, bareKey } = this._splitKey(key, namespace);
989
+ const a11yNs = this._a11yNs(ns, surface);
990
+ if (!this._i18next.exists(bareKey, { ns: a11yNs })) return void 0;
991
+ return this._i18next.t(bareKey, { ns: a11yNs });
992
+ };
993
+ /** Accessible name for `key` (`aria_label` overlay) — falls back to the
994
+ * visible `t(key)` text when no override exists. Spread as `aria-label`. */
995
+ aria = (key, namespace) => {
996
+ const v = this.a11y(key, "aria_label", namespace);
997
+ if (v !== void 0) return v;
998
+ const { ns, bareKey } = this._splitKey(key, namespace);
999
+ return this._i18next.t(bareKey, { ns });
1000
+ };
1001
+ /** Image alt text for `key` (`alt_text` overlay) — falls back to the visible
1002
+ * `t(key)` text. Pair with {@link a11yAsset} for a localized image. */
1003
+ alt = (key, namespace) => {
1004
+ const v = this.a11y(key, "alt_text", namespace);
1005
+ if (v !== void 0) return v;
1006
+ const { ns, bareKey } = this._splitKey(key, namespace);
1007
+ return this._i18next.t(bareKey, { ns });
1008
+ };
1009
+ /** Localized-image `$asset` ref from a key's `alt_text` overlay, or
1010
+ * `undefined`. Walks the locale fallback chain like `t()` would. */
1011
+ a11yAsset = (key, namespace) => {
1012
+ const { ns, bareKey } = this._splitKey(key, namespace);
1013
+ for (const loc of this._resolutionChain(this.locale)) {
1014
+ const ref = this._a11yAssets.get(`${loc}/${ns}/${bareKey}#alt_text`);
1015
+ if (ref) return ref;
1016
+ }
1017
+ return void 0;
1018
+ };
1019
+ get plainLanguage() {
1020
+ return this._plainLanguage;
1021
+ }
1022
+ /** Toggle simplified-language ("plain language" / FALC) mode (#989). When
1023
+ * on, `t()` returns the `plain_language` overlay value for keys that have
1024
+ * one. Loads the `plain_language` overlays on first enable if they were not
1025
+ * configured, then re-renders. No-op when unchanged. */
1026
+ setPlainLanguage = async (on) => {
1027
+ if (on === this._plainLanguage) return;
1028
+ this._plainLanguage = on;
1029
+ if (on && !this._a11ySurfaces.has("plain_language")) {
1030
+ this._a11ySurfaces.add("plain_language");
1031
+ const targets = [];
1032
+ for (const k of this._attempted) {
1033
+ const parts = k.split("/");
1034
+ const locale = parts[1];
1035
+ const ns = parts[2];
1036
+ if (locale && ns) targets.push({ locale, ns });
1037
+ }
1038
+ await Promise.all(
1039
+ targets.map((t2) => this._loadA11yOverlays(t2.locale, t2.ns))
1040
+ );
1041
+ }
1042
+ this._notify();
1043
+ this._signalLoaded();
1044
+ };
871
1045
  // ---- Translation ----
872
1046
  t = (key, optionsOrDefault, maybeOptions) => {
873
1047
  const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
1048
+ if (this._plainLanguage && this._a11ySurfaces.has("plain_language")) {
1049
+ const { ns, bareKey } = this._splitKey(key);
1050
+ const plainNs = this._a11yNs(ns, "plain_language");
1051
+ if (this._i18next.exists(bareKey, { ...options ?? {}, ns: plainNs })) {
1052
+ return this._i18next.t(bareKey, {
1053
+ ...options ?? {},
1054
+ ns: plainNs
1055
+ });
1056
+ }
1057
+ }
874
1058
  const literal = this._probeLiteral(key);
875
1059
  if (literal !== void 0) {
876
1060
  const interpolator = this._i18next.services?.interpolator;
@@ -1021,6 +1205,67 @@ var SonentaI18n = class {
1021
1205
  this._attempted.add(cacheKey);
1022
1206
  }
1023
1207
  await this._composeBundle(locale, ns, fetchImpl, opts.bust);
1208
+ await this._loadA11yOverlays(locale, ns, fetchImpl, opts.bust);
1209
+ }
1210
+ /**
1211
+ * Load every configured a11y surface overlay (#989 / task 994) for
1212
+ * (locale, ns) into a DEDICATED i18next namespace `${ns}__${surface}`, so
1213
+ * `aria()` / `alt()` / `a11y()` resolve them through i18next (locale
1214
+ * fallback + nested keys + plurals) WITHOUT polluting the visible-text
1215
+ * bundle. Sparse + best-effort: an absent overlay registers `{}` and the
1216
+ * accessor reports "no override".
1217
+ */
1218
+ async _loadA11yOverlays(locale, ns, fetchImpl = fetch, bust = false) {
1219
+ if (this._a11ySurfaces.size === 0) return;
1220
+ await Promise.all(
1221
+ [...this._a11ySurfaces].map(async (surface) => {
1222
+ const overlay = await this._loadOverlay(
1223
+ locale,
1224
+ ns,
1225
+ surface,
1226
+ fetchImpl,
1227
+ bust
1228
+ );
1229
+ const tree = this._unwrapA11y(overlay, locale, ns, surface);
1230
+ this._i18next.addResourceBundle(
1231
+ locale,
1232
+ this._a11yNs(ns, surface),
1233
+ flattenPlurals(tree, locale),
1234
+ false,
1235
+ true
1236
+ );
1237
+ })
1238
+ );
1239
+ }
1240
+ /** i18next namespace that backs an a11y surface overlay for `ns`. */
1241
+ _a11yNs(ns, surface) {
1242
+ return `${ns}__${surface}`;
1243
+ }
1244
+ /** Like {@link _unwrapAssets} but records `$asset` refs into `_a11yAssets`
1245
+ * (keyed with the `#${surface}` suffix) instead of the visible-text
1246
+ * `_assets`, so `alt_text` images don't collide with device-surface assets. */
1247
+ _unwrapA11y(tree, locale, ns, surface) {
1248
+ const sep = typeof this._i18next.options.keySeparator === "string" ? this._i18next.options.keySeparator : ".";
1249
+ const walk = (node, path) => {
1250
+ if (!node || typeof node !== "object") return node;
1251
+ const obj = node;
1252
+ if (Object.prototype.hasOwnProperty.call(obj, "$value")) {
1253
+ const a = obj.$asset;
1254
+ if (a && typeof a.kind === "string" && typeof a.ref === "string") {
1255
+ this._a11yAssets.set(
1256
+ `${locale}/${ns}/${path.join(sep)}#${surface}`,
1257
+ { kind: a.kind, ref: a.ref }
1258
+ );
1259
+ }
1260
+ return obj.$value;
1261
+ }
1262
+ const out = {};
1263
+ for (const [k, v] of Object.entries(obj)) {
1264
+ out[k] = walk(v, [...path, k]);
1265
+ }
1266
+ return out;
1267
+ };
1268
+ return walk(tree, []);
1024
1269
  }
1025
1270
  /**
1026
1271
  * Compose the i18next bundle for (locale, ns) as base ⊕ surface overlay
@@ -1148,6 +1393,12 @@ function t(key, optionsOrDefault, maybeOptions) {
1148
1393
  }
1149
1394
 
1150
1395
  // src/surface.ts
1396
+ var A11Y_SURFACES = [
1397
+ "aria_label",
1398
+ "alt_text",
1399
+ "screen_reader",
1400
+ "plain_language"
1401
+ ];
1151
1402
  var DEFAULT_SURFACE_BREAKPOINTS = {
1152
1403
  mobile: 640,
1153
1404
  tablet: 1024
@@ -1231,7 +1482,12 @@ function useTranslation(defaultNamespace) {
1231
1482
  );
1232
1483
  return i18n.t(fullKey, optionsOrDefault, maybeOptions);
1233
1484
  };
1234
- return fn;
1485
+ const withNs = (key) => defaultNamespace && !key.includes(":") ? `${defaultNamespace}:${key}` : key;
1486
+ const aug = fn;
1487
+ aug.aria = (key, namespace) => i18n.aria(withNs(key), namespace);
1488
+ aug.alt = (key, namespace) => i18n.alt(withNs(key), namespace);
1489
+ aug.a11y = (key, surface, namespace) => i18n.a11y(withNs(key), surface, namespace);
1490
+ return aug;
1235
1491
  }, [i18n, defaultNamespace]);
1236
1492
  useEffect2(() => {
1237
1493
  keyRegistry._set(tokenRef.current, renderedRef.current);
@@ -1242,6 +1498,9 @@ function useTranslation(defaultNamespace) {
1242
1498
  }, []);
1243
1499
  return { t: t2, i18n: snapshot };
1244
1500
  }
1501
+ function useAvailableLanguages() {
1502
+ return useI18nSnapshot().availableLanguages;
1503
+ }
1245
1504
 
1246
1505
  // src/trans.tsx
1247
1506
  import { Children, cloneElement, isValidElement } from "react";
@@ -1283,6 +1542,7 @@ function splitOnComponents(text, components) {
1283
1542
  return out;
1284
1543
  }
1285
1544
  export {
1545
+ A11Y_SURFACES,
1286
1546
  DEFAULT_SURFACE_BREAKPOINTS,
1287
1547
  SonentaProvider,
1288
1548
  Trans,
@@ -1294,6 +1554,7 @@ export {
1294
1554
  logTransport,
1295
1555
  surfaceForWidth,
1296
1556
  t,
1557
+ useAvailableLanguages,
1297
1558
  useTranslation
1298
1559
  };
1299
1560
  //# sourceMappingURL=index.js.map