@sonenta/react-i18next 2.1.0 → 2.2.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
@@ -497,6 +497,49 @@ Plurals work the same in overlays (single key + CLDR plural forms); an overlay
497
497
  plural set fully replaces the base key's. Surface overlays are served from the
498
498
  CDN; `env: "dev"` is base-only for now.
499
499
 
500
+ ## Accessibility surfaces
501
+
502
+ A11y variants attach SEMANTIC accessibility text to a key — `aria_label`,
503
+ `alt_text`, `screen_reader`, `plain_language` — delivered through the same
504
+ sparse-overlay engine as device surfaces, but applied **orthogonally** to the
505
+ visible text (an element has both its visible label AND an accessible name).
506
+ Opt in per surface; they load alongside the base bundles.
507
+
508
+ ```tsx
509
+ <SonentaProvider {...config} a11ySurfaces={["aria_label", "alt_text"]}>
510
+ <App />
511
+ </SonentaProvider>
512
+
513
+ function SaveButton() {
514
+ const { t } = useTranslation("common");
515
+ return (
516
+ <button aria-label={t.aria("save")}>{t("save")}</button>
517
+ );
518
+ // t("save") → visible text ("Save")
519
+ // t.aria("save") → aria_label overlay, or the visible text if no override
520
+ }
521
+
522
+ function Hero() {
523
+ const { i18n } = useTranslation();
524
+ return <img src={i18n.a11yAsset("hero")?.ref} alt={i18n.alt("hero")} />;
525
+ }
526
+ ```
527
+
528
+ - `t.aria(key)` / `t.alt(key)` — overlay value, **falling back to the visible
529
+ text** when no a11y override exists. Also on the instance: `i18n.aria()` /
530
+ `i18n.alt()`.
531
+ - `t.a11y(key, surface)` / `i18n.a11y(key, surface)` — the raw resolver:
532
+ returns `undefined` when there's no override (use for `screen_reader` /
533
+ `plain_language`, which should be **omitted** rather than fall back).
534
+ - `i18n.a11yAsset(key)` — the `alt_text` overlay's localized-image `$asset`.
535
+ - **Plain language (cognitive) toggle:** include `plain_language` (or pass
536
+ `plainLanguage`) and call `i18n.setPlainLanguage(true)` — `t()` then returns
537
+ the `plain_language` overlay for keys that have one (else the base text).
538
+
539
+ Resolution is locale-outer / surface-inner (same chain as `t()`): `(fr-CA,
540
+ aria_label) > (fr-CA, base) > (fr, aria_label) > (fr, base)`. Overlays are
541
+ CDN-only (`env: "dev"` is base-only).
542
+
500
543
  ## Recipes
501
544
 
502
545
  ### Next.js (App Router)
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ A11Y_SURFACES: () => A11Y_SURFACES,
23
24
  DEFAULT_SURFACE_BREAKPOINTS: () => DEFAULT_SURFACE_BREAKPOINTS,
24
25
  SonentaProvider: () => SonentaProvider,
25
26
  Trans: () => Trans,
@@ -236,7 +237,7 @@ function flattenPlurals(tree, locale) {
236
237
 
237
238
  // src/transport.ts
238
239
  var SDK_LIB = "@sonenta/react-i18next";
239
- var SDK_VER = true ? "2.1.0" : "0.0.0-dev";
240
+ var SDK_VER = true ? "2.2.0" : "0.0.0-dev";
240
241
  function defaultTransport(opts) {
241
242
  return async (batch) => {
242
243
  if (!batch.length) return;
@@ -442,6 +443,14 @@ var SonentaI18n = class {
442
443
  // Resolved asset refs (#911 minimal v1), keyed `${locale}/${ns}/${keyPath}`
443
444
  // for the CURRENT composition (base assets, then overlay assets override).
444
445
  _assets = /* @__PURE__ */ new Map();
446
+ // A11y surfaces (#989 / task 994). Each configured a11y surface S is loaded
447
+ // as its OWN i18next namespace `${ns}__${S}` (sparse `{ns}.{S}.json`), so the
448
+ // accessors resolve through i18next's native locale-fallback + nested-key
449
+ // logic without touching the visible-text bundle. `_a11yAssets` carries the
450
+ // `alt_text` `$asset` refs, keyed `${locale}/${ns}/${keyPath}#${surface}`.
451
+ _a11ySurfaces = /* @__PURE__ */ new Set();
452
+ _plainLanguage = false;
453
+ _a11yAssets = /* @__PURE__ */ new Map();
445
454
  _missing;
446
455
  _listeners = /* @__PURE__ */ new Set();
447
456
  // Stable snapshot reference for useSyncExternalStore. Rebuilt ONLY in _notify
@@ -469,6 +478,9 @@ var SonentaI18n = class {
469
478
  this.locale = config.defaultLocale;
470
479
  this.fallbackLng = config.fallbackLng;
471
480
  this._surface = config.surface;
481
+ for (const s of config.a11ySurfaces ?? []) this._a11ySurfaces.add(s);
482
+ this._plainLanguage = config.plainLanguage === true;
483
+ if (this._plainLanguage) this._a11ySurfaces.add("plain_language");
472
484
  let keySeparator = ".";
473
485
  if (config.keySeparator !== void 0) {
474
486
  keySeparator = config.keySeparator;
@@ -653,6 +665,12 @@ var SonentaI18n = class {
653
665
  surface: this._surface,
654
666
  setSurface: this.setSurface,
655
667
  asset: this.asset,
668
+ aria: this.aria,
669
+ alt: this.alt,
670
+ a11y: this.a11y,
671
+ a11yAsset: this.a11yAsset,
672
+ plainLanguage: this._plainLanguage,
673
+ setPlainLanguage: this.setPlainLanguage,
656
674
  dir: this.dir,
657
675
  nativeName: this.nativeName,
658
676
  languageMeta: this.languageMeta,
@@ -894,9 +912,96 @@ var SonentaI18n = class {
894
912
  }
895
913
  return void 0;
896
914
  };
915
+ // ---- A11y (#989 / task 994) ----
916
+ /** Split a possibly-`ns:key` string into `{ ns, bareKey }`, honoring the
917
+ * configured nsSeparator (mirrors {@link asset}). */
918
+ _splitKey(key, namespace) {
919
+ let ns = namespace ?? this.defaultNamespace;
920
+ let bareKey = key;
921
+ if (typeof this._nsSeparator === "string" && this._nsSeparator) {
922
+ const idx = key.indexOf(this._nsSeparator);
923
+ if (idx > 0) {
924
+ ns = key.slice(0, idx);
925
+ bareKey = key.slice(idx + this._nsSeparator.length);
926
+ }
927
+ }
928
+ return { ns, bareKey };
929
+ }
930
+ /** Resolve an a11y `surface` override for `key`, or `undefined` when none
931
+ * exists (the namespace is sparse). Goes through i18next so the locale
932
+ * fallback chain + nested keys + plurals apply. */
933
+ a11y = (key, surface, namespace) => {
934
+ if (!this._a11ySurfaces.has(surface)) return void 0;
935
+ const { ns, bareKey } = this._splitKey(key, namespace);
936
+ const a11yNs = this._a11yNs(ns, surface);
937
+ if (!this._i18next.exists(bareKey, { ns: a11yNs })) return void 0;
938
+ return this._i18next.t(bareKey, { ns: a11yNs });
939
+ };
940
+ /** Accessible name for `key` (`aria_label` overlay) — falls back to the
941
+ * visible `t(key)` text when no override exists. Spread as `aria-label`. */
942
+ aria = (key, namespace) => {
943
+ const v = this.a11y(key, "aria_label", namespace);
944
+ if (v !== void 0) return v;
945
+ const { ns, bareKey } = this._splitKey(key, namespace);
946
+ return this._i18next.t(bareKey, { ns });
947
+ };
948
+ /** Image alt text for `key` (`alt_text` overlay) — falls back to the visible
949
+ * `t(key)` text. Pair with {@link a11yAsset} for a localized image. */
950
+ alt = (key, namespace) => {
951
+ const v = this.a11y(key, "alt_text", namespace);
952
+ if (v !== void 0) return v;
953
+ const { ns, bareKey } = this._splitKey(key, namespace);
954
+ return this._i18next.t(bareKey, { ns });
955
+ };
956
+ /** Localized-image `$asset` ref from a key's `alt_text` overlay, or
957
+ * `undefined`. Walks the locale fallback chain like `t()` would. */
958
+ a11yAsset = (key, namespace) => {
959
+ const { ns, bareKey } = this._splitKey(key, namespace);
960
+ for (const loc of this._resolutionChain(this.locale)) {
961
+ const ref = this._a11yAssets.get(`${loc}/${ns}/${bareKey}#alt_text`);
962
+ if (ref) return ref;
963
+ }
964
+ return void 0;
965
+ };
966
+ get plainLanguage() {
967
+ return this._plainLanguage;
968
+ }
969
+ /** Toggle simplified-language ("plain language" / FALC) mode (#989). When
970
+ * on, `t()` returns the `plain_language` overlay value for keys that have
971
+ * one. Loads the `plain_language` overlays on first enable if they were not
972
+ * configured, then re-renders. No-op when unchanged. */
973
+ setPlainLanguage = async (on) => {
974
+ if (on === this._plainLanguage) return;
975
+ this._plainLanguage = on;
976
+ if (on && !this._a11ySurfaces.has("plain_language")) {
977
+ this._a11ySurfaces.add("plain_language");
978
+ const targets = [];
979
+ for (const k of this._attempted) {
980
+ const parts = k.split("/");
981
+ const locale = parts[1];
982
+ const ns = parts[2];
983
+ if (locale && ns) targets.push({ locale, ns });
984
+ }
985
+ await Promise.all(
986
+ targets.map((t2) => this._loadA11yOverlays(t2.locale, t2.ns))
987
+ );
988
+ }
989
+ this._notify();
990
+ this._signalLoaded();
991
+ };
897
992
  // ---- Translation ----
898
993
  t = (key, optionsOrDefault, maybeOptions) => {
899
994
  const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
995
+ if (this._plainLanguage && this._a11ySurfaces.has("plain_language")) {
996
+ const { ns, bareKey } = this._splitKey(key);
997
+ const plainNs = this._a11yNs(ns, "plain_language");
998
+ if (this._i18next.exists(bareKey, { ...options ?? {}, ns: plainNs })) {
999
+ return this._i18next.t(bareKey, {
1000
+ ...options ?? {},
1001
+ ns: plainNs
1002
+ });
1003
+ }
1004
+ }
900
1005
  const literal = this._probeLiteral(key);
901
1006
  if (literal !== void 0) {
902
1007
  const interpolator = this._i18next.services?.interpolator;
@@ -1047,6 +1152,67 @@ var SonentaI18n = class {
1047
1152
  this._attempted.add(cacheKey);
1048
1153
  }
1049
1154
  await this._composeBundle(locale, ns, fetchImpl, opts.bust);
1155
+ await this._loadA11yOverlays(locale, ns, fetchImpl, opts.bust);
1156
+ }
1157
+ /**
1158
+ * Load every configured a11y surface overlay (#989 / task 994) for
1159
+ * (locale, ns) into a DEDICATED i18next namespace `${ns}__${surface}`, so
1160
+ * `aria()` / `alt()` / `a11y()` resolve them through i18next (locale
1161
+ * fallback + nested keys + plurals) WITHOUT polluting the visible-text
1162
+ * bundle. Sparse + best-effort: an absent overlay registers `{}` and the
1163
+ * accessor reports "no override".
1164
+ */
1165
+ async _loadA11yOverlays(locale, ns, fetchImpl = fetch, bust = false) {
1166
+ if (this._a11ySurfaces.size === 0) return;
1167
+ await Promise.all(
1168
+ [...this._a11ySurfaces].map(async (surface) => {
1169
+ const overlay = await this._loadOverlay(
1170
+ locale,
1171
+ ns,
1172
+ surface,
1173
+ fetchImpl,
1174
+ bust
1175
+ );
1176
+ const tree = this._unwrapA11y(overlay, locale, ns, surface);
1177
+ this._i18next.addResourceBundle(
1178
+ locale,
1179
+ this._a11yNs(ns, surface),
1180
+ flattenPlurals(tree, locale),
1181
+ false,
1182
+ true
1183
+ );
1184
+ })
1185
+ );
1186
+ }
1187
+ /** i18next namespace that backs an a11y surface overlay for `ns`. */
1188
+ _a11yNs(ns, surface) {
1189
+ return `${ns}__${surface}`;
1190
+ }
1191
+ /** Like {@link _unwrapAssets} but records `$asset` refs into `_a11yAssets`
1192
+ * (keyed with the `#${surface}` suffix) instead of the visible-text
1193
+ * `_assets`, so `alt_text` images don't collide with device-surface assets. */
1194
+ _unwrapA11y(tree, locale, ns, surface) {
1195
+ const sep = typeof this._i18next.options.keySeparator === "string" ? this._i18next.options.keySeparator : ".";
1196
+ const walk = (node, path) => {
1197
+ if (!node || typeof node !== "object") return node;
1198
+ const obj = node;
1199
+ if (Object.prototype.hasOwnProperty.call(obj, "$value")) {
1200
+ const a = obj.$asset;
1201
+ if (a && typeof a.kind === "string" && typeof a.ref === "string") {
1202
+ this._a11yAssets.set(
1203
+ `${locale}/${ns}/${path.join(sep)}#${surface}`,
1204
+ { kind: a.kind, ref: a.ref }
1205
+ );
1206
+ }
1207
+ return obj.$value;
1208
+ }
1209
+ const out = {};
1210
+ for (const [k, v] of Object.entries(obj)) {
1211
+ out[k] = walk(v, [...path, k]);
1212
+ }
1213
+ return out;
1214
+ };
1215
+ return walk(tree, []);
1050
1216
  }
1051
1217
  /**
1052
1218
  * Compose the i18next bundle for (locale, ns) as base ⊕ surface overlay
@@ -1174,6 +1340,12 @@ function t(key, optionsOrDefault, maybeOptions) {
1174
1340
  }
1175
1341
 
1176
1342
  // src/surface.ts
1343
+ var A11Y_SURFACES = [
1344
+ "aria_label",
1345
+ "alt_text",
1346
+ "screen_reader",
1347
+ "plain_language"
1348
+ ];
1177
1349
  var DEFAULT_SURFACE_BREAKPOINTS = {
1178
1350
  mobile: 640,
1179
1351
  tablet: 1024
@@ -1257,7 +1429,12 @@ function useTranslation(defaultNamespace) {
1257
1429
  );
1258
1430
  return i18n.t(fullKey, optionsOrDefault, maybeOptions);
1259
1431
  };
1260
- return fn;
1432
+ const withNs = (key) => defaultNamespace && !key.includes(":") ? `${defaultNamespace}:${key}` : key;
1433
+ const aug = fn;
1434
+ aug.aria = (key, namespace) => i18n.aria(withNs(key), namespace);
1435
+ aug.alt = (key, namespace) => i18n.alt(withNs(key), namespace);
1436
+ aug.a11y = (key, surface, namespace) => i18n.a11y(withNs(key), surface, namespace);
1437
+ return aug;
1261
1438
  }, [i18n, defaultNamespace]);
1262
1439
  (0, import_react2.useEffect)(() => {
1263
1440
  keyRegistry._set(tokenRef.current, renderedRef.current);
@@ -1310,6 +1487,7 @@ function splitOnComponents(text, components) {
1310
1487
  }
1311
1488
  // Annotate the CommonJS export names for ESM import in node:
1312
1489
  0 && (module.exports = {
1490
+ A11Y_SURFACES,
1313
1491
  DEFAULT_SURFACE_BREAKPOINTS,
1314
1492
  SonentaProvider,
1315
1493
  Trans,