@ayinza_dev/i18n-config 1.4.1 → 1.5.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/dist/config.js CHANGED
@@ -57,6 +57,7 @@ const mergeFormatters = (base, override) => {
57
57
  ...override?.date,
58
58
  },
59
59
  fallbackLocale: override?.fallbackLocale ?? base?.fallbackLocale,
60
+ numberingSystem: override?.numberingSystem ?? base?.numberingSystem,
60
61
  };
61
62
  };
62
63
  function mergeConfig(base, override) {
@@ -4,9 +4,16 @@ export declare class I18nFormatters {
4
4
  private numberConfig;
5
5
  private dateConfig;
6
6
  private fallbackLocale;
7
+ private numberingSystem;
7
8
  private normalizeLanguage;
8
9
  constructor(config?: FormattersConfig);
9
10
  private getLocale;
11
+ /**
12
+ * Force a numbering system across every Intl formatter (incl. RelativeTime,
13
+ * which has no `numberingSystem` option) by appending the Unicode `-u-nu-`
14
+ * locale extension. No-op when disabled or already present.
15
+ */
16
+ private applyNumberingSystem;
10
17
  /**
11
18
  * Format with locale-specific formatting
12
19
  */
@@ -21,6 +21,10 @@ export class I18nFormatters {
21
21
  constructor(config = {}) {
22
22
  this.fallbackLocale =
23
23
  config.fallbackLocale || defaultLocaleMapping.en || "en-US";
24
+ // Default to Western (latn) digits so localized output keeps native words
25
+ // (month/weekday/currency labels) and RTL but renders 0-9. `??` (not `||`)
26
+ // so an explicit "" opts back into each locale's native numbering.
27
+ this.numberingSystem = config.numberingSystem ?? "latn";
24
28
  this.currencyConfig = {
25
29
  defaultCurrency: config.currency?.defaultCurrency || "USD",
26
30
  localeMapping: {
@@ -48,7 +52,19 @@ export class I18nFormatters {
48
52
  }
49
53
  getLocale(language, mapping) {
50
54
  const { full, base } = this.normalizeLanguage(language);
51
- return mapping[full] || mapping[base] || this.fallbackLocale;
55
+ const locale = mapping[full] || mapping[base] || this.fallbackLocale;
56
+ return this.applyNumberingSystem(locale);
57
+ }
58
+ /**
59
+ * Force a numbering system across every Intl formatter (incl. RelativeTime,
60
+ * which has no `numberingSystem` option) by appending the Unicode `-u-nu-`
61
+ * locale extension. No-op when disabled or already present.
62
+ */
63
+ applyNumberingSystem(locale) {
64
+ if (!this.numberingSystem || locale.includes("-u-nu-")) {
65
+ return locale;
66
+ }
67
+ return `${locale}-u-nu-${this.numberingSystem}`;
52
68
  }
53
69
  /**
54
70
  * Format with locale-specific formatting
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type { I18nConfig, I18nInitOptions, FormattersConfig, LocaleMapping, Pars
3
3
  export { I18nFormatters } from "./formatters.js";
4
4
  export { useFormatting, useI18n } from "./hooks.js";
5
5
  export { createI18nextParserConfig } from "./parser-config.js";
6
- export { createTranslationSnapshot, collectNewTranslationKeys, handleNewTranslationKeys, } from "./parser-hooks.js";
6
+ export { createTranslationSnapshot, collectNewTranslationKeys, handleNewTranslationKeys, publishTranslationOverrides, resolveSourcePushUrl, resolveOverridePushUrl, TRANSLATE_SEND_PATH, ADMIN_TRANSLATIONS_PATH, } from "./parser-hooks.js";
7
+ export type { PublishTranslationOverridesOptions } from "./parser-hooks.js";
7
8
  export { useTranslation, Trans, Translation } from "react-i18next";
8
9
  export { type TFunction } from "i18next";
package/dist/index.js CHANGED
@@ -2,6 +2,6 @@ export { initializeI18n, getI18nInstance, getFormatters, defaultConfig, createI1
2
2
  export { I18nFormatters } from "./formatters.js";
3
3
  export { useFormatting, useI18n } from "./hooks.js";
4
4
  export { createI18nextParserConfig } from "./parser-config.js";
5
- export { createTranslationSnapshot, collectNewTranslationKeys, handleNewTranslationKeys, } from "./parser-hooks.js";
5
+ export { createTranslationSnapshot, collectNewTranslationKeys, handleNewTranslationKeys, publishTranslationOverrides, resolveSourcePushUrl, resolveOverridePushUrl, TRANSLATE_SEND_PATH, ADMIN_TRANSLATIONS_PATH, } from "./parser-hooks.js";
6
6
  // Re-export commonly used utilities from react-i18next
7
7
  export { useTranslation, Trans, Translation } from "react-i18next";
@@ -1,4 +1,27 @@
1
1
  import type { NewKeyPayload, ParserPushConfig, TranslationSnapshot } from "./types.js";
2
+ /**
3
+ * Localization-service endpoint paths, appended to the API base URL
4
+ * (`VITE_LOCALIZATION_API_BASE_URL`, e.g. `https://…/api/v1`). Owning them here is
5
+ * the single source of truth so no consumer hardcodes (and mis-types) the paths —
6
+ * the override route in particular lives under `/l10n/admin/translations`, NOT the
7
+ * bare `/admin/translations`.
8
+ */
9
+ export declare const TRANSLATE_SEND_PATH = "/l10n/translate/send";
10
+ export declare const ADMIN_TRANSLATIONS_PATH = "/l10n/admin/translations";
11
+ /**
12
+ * Build the source-key registration URL (POST) from an API base URL — the
13
+ * `pushUrl` for {@link handleNewTranslationKeys}.
14
+ *
15
+ * @example resolveSourcePushUrl("https://loc/api/v1") // ".../api/v1/l10n/translate/send"
16
+ */
17
+ export declare const resolveSourcePushUrl: (apiBaseUrl: string) => string;
18
+ /**
19
+ * Build the human-override collection URL from an API base URL — the `pushUrl`
20
+ * for {@link publishTranslationOverrides}, which appends `/{locale}` per request.
21
+ *
22
+ * @example resolveOverridePushUrl("https://loc/api/v1") // ".../api/v1/l10n/admin/translations"
23
+ */
24
+ export declare const resolveOverridePushUrl: (apiBaseUrl: string) => string;
2
25
  export interface CreateTranslationSnapshotOptions {
3
26
  locale: string;
4
27
  namespaces: Record<string, Record<string, unknown>>;
@@ -21,3 +44,24 @@ export interface HandleNewTranslationKeysResult {
21
44
  export declare const createTranslationSnapshot: (options: CreateTranslationSnapshotOptions) => TranslationSnapshot;
22
45
  export declare const collectNewTranslationKeys: (options: CollectNewTranslationKeysOptions) => NewKeyPayload[];
23
46
  export declare const handleNewTranslationKeys: (options: HandleNewTranslationKeysOptions) => Promise<HandleNewTranslationKeysResult>;
47
+ export interface PublishTranslationOverridesOptions {
48
+ /** Target locale whose values are being corrected, e.g. `ar-SA`. */
49
+ locale: string;
50
+ /** Flat `key -> corrected value` map. Only keys present are overridden. */
51
+ translations: Record<string, string>;
52
+ /**
53
+ * Push settings. `pushUrl` is the override collection endpoint (e.g.
54
+ * `${base}/l10n/admin/translations`); the locale is appended per request.
55
+ */
56
+ pushConfig: ParserPushConfig;
57
+ requestInit?: RequestInit;
58
+ logger?: Pick<Console, "log" | "warn" | "error">;
59
+ }
60
+ /**
61
+ * Publish curated translation overrides for a locale to the localization
62
+ * service's upsert endpoint (`PUT /l10n/admin/translations/{locale}`). Any Ayinza
63
+ * product uses this to correct its own catalog (e.g. replacing garbled machine
64
+ * translations); the service marks the rows human-authoritative so the MT
65
+ * sweep never re-manufactures them.
66
+ */
67
+ export declare const publishTranslationOverrides: (options: PublishTranslationOverridesOptions) => Promise<HandleNewTranslationKeysResult>;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Localization-service endpoint paths, appended to the API base URL
3
+ * (`VITE_LOCALIZATION_API_BASE_URL`, e.g. `https://…/api/v1`). Owning them here is
4
+ * the single source of truth so no consumer hardcodes (and mis-types) the paths —
5
+ * the override route in particular lives under `/l10n/admin/translations`, NOT the
6
+ * bare `/admin/translations`.
7
+ */
8
+ export const TRANSLATE_SEND_PATH = "/l10n/translate/send";
9
+ export const ADMIN_TRANSLATIONS_PATH = "/l10n/admin/translations";
10
+ const stripTrailingSlashes = (url) => url.replace(/\/+$/, "");
11
+ /**
12
+ * Build the source-key registration URL (POST) from an API base URL — the
13
+ * `pushUrl` for {@link handleNewTranslationKeys}.
14
+ *
15
+ * @example resolveSourcePushUrl("https://loc/api/v1") // ".../api/v1/l10n/translate/send"
16
+ */
17
+ export const resolveSourcePushUrl = (apiBaseUrl) => `${stripTrailingSlashes(apiBaseUrl)}${TRANSLATE_SEND_PATH}`;
18
+ /**
19
+ * Build the human-override collection URL from an API base URL — the `pushUrl`
20
+ * for {@link publishTranslationOverrides}, which appends `/{locale}` per request.
21
+ *
22
+ * @example resolveOverridePushUrl("https://loc/api/v1") // ".../api/v1/l10n/admin/translations"
23
+ */
24
+ export const resolveOverridePushUrl = (apiBaseUrl) => `${stripTrailingSlashes(apiBaseUrl)}${ADMIN_TRANSLATIONS_PATH}`;
1
25
  const formatValue = (value) => {
2
26
  if (value === undefined || value === null) {
3
27
  return "";
@@ -76,31 +100,22 @@ const headersToRecord = (headers) => {
76
100
  }
77
101
  return { ...headers };
78
102
  };
79
- export const handleNewTranslationKeys = async (options) => {
80
- const { newKeys, pushConfig, requestInit, logger = console } = options;
81
- const dryRun = pushConfig.dryRun || !pushConfig.pushUrl;
82
- if (!newKeys.length) {
83
- logger.log(`🗒️ No new translation keys for ${pushConfig.portalName}.`);
84
- return { pushed: 0, dryRun };
85
- }
86
- const translations = {};
87
- for (const keyEntry of newKeys) {
88
- translations[keyEntry.key] = keyEntry.defaultValue;
89
- }
103
+ /**
104
+ * Send a `{ data: { translations, category? } }` catalog payload to the
105
+ * localization-service. Shared by the source-key push (POST /translate/send)
106
+ * and the override publish (PUT /l10n/admin/translations/{locale}).
107
+ */
108
+ const sendCatalogPayload = async (options) => {
109
+ const { url, method, translations, pushConfig, requestInit } = options;
90
110
  const payload = {
91
111
  data: {
92
112
  translations,
93
- // Tag every pushed key with the producer's category when configured so
113
+ // Tag the payload with the producer's category when configured so
94
114
  // consumers can fetch a scoped catalog. Omitted entirely when unset to
95
115
  // preserve the un-categorised (shared) default.
96
116
  ...(pushConfig.category ? { category: pushConfig.category } : {}),
97
117
  },
98
118
  };
99
- if (dryRun) {
100
- logger.log(`💡 Dry run for ${pushConfig.portalName}:\n` +
101
- newKeys.map((key) => ` • ${key.namespace}:${key.key}`).join("\n"));
102
- return { pushed: 0, dryRun: true };
103
- }
104
119
  const fetchImpl = pushConfig.fetchImpl ?? globalThis.fetch;
105
120
  if (!fetchImpl) {
106
121
  throw new Error("No fetch implementation available. Provide pushConfig.fetchImpl in non-Node 18 environments.");
@@ -113,17 +128,74 @@ export const handleNewTranslationKeys = async (options) => {
113
128
  headerBag.Authorization = `Bearer ${pushConfig.authorizationToken}`;
114
129
  }
115
130
  Object.assign(headerBag, headersToRecord(requestInit?.headers));
116
- const init = {
131
+ const response = await fetchImpl(url, {
117
132
  ...requestInit,
118
- method: "POST",
133
+ method,
119
134
  body: JSON.stringify(payload),
120
135
  headers: headerBag,
121
- };
122
- const response = await fetchImpl(pushConfig.pushUrl, init);
136
+ });
123
137
  if (!response.ok) {
124
138
  const errorBody = await response.text().catch(() => "");
125
- throw new Error(`Failed to push new translation keys (${response.status}): ${errorBody}`);
139
+ throw new Error(`Failed to ${method} translations (${response.status}): ${errorBody}`);
140
+ }
141
+ };
142
+ export const handleNewTranslationKeys = async (options) => {
143
+ const { newKeys, pushConfig, requestInit, logger = console } = options;
144
+ const dryRun = pushConfig.dryRun || !pushConfig.pushUrl;
145
+ if (!newKeys.length) {
146
+ logger.log(`🗒️ No new translation keys for ${pushConfig.portalName}.`);
147
+ return { pushed: 0, dryRun };
148
+ }
149
+ const translations = {};
150
+ for (const keyEntry of newKeys) {
151
+ translations[keyEntry.key] = keyEntry.defaultValue;
126
152
  }
153
+ if (dryRun) {
154
+ logger.log(`💡 Dry run for ${pushConfig.portalName}:\n` +
155
+ newKeys.map((key) => ` • ${key.namespace}:${key.key}`).join("\n"));
156
+ return { pushed: 0, dryRun: true };
157
+ }
158
+ await sendCatalogPayload({
159
+ url: pushConfig.pushUrl,
160
+ method: "POST",
161
+ translations,
162
+ pushConfig,
163
+ requestInit,
164
+ });
127
165
  logger.log(`✅ Pushed ${newKeys.length} keys for ${pushConfig.portalName}.`);
128
166
  return { pushed: newKeys.length, dryRun: false };
129
167
  };
168
+ /**
169
+ * Publish curated translation overrides for a locale to the localization
170
+ * service's upsert endpoint (`PUT /l10n/admin/translations/{locale}`). Any Ayinza
171
+ * product uses this to correct its own catalog (e.g. replacing garbled machine
172
+ * translations); the service marks the rows human-authoritative so the MT
173
+ * sweep never re-manufactures them.
174
+ */
175
+ export const publishTranslationOverrides = async (options) => {
176
+ const { locale, translations, pushConfig, requestInit, logger = console } = options;
177
+ const count = Object.keys(translations).length;
178
+ const dryRun = pushConfig.dryRun || !pushConfig.pushUrl;
179
+ if (!count) {
180
+ logger.log(`🗒️ No translation overrides for ${pushConfig.portalName}.`);
181
+ return { pushed: 0, dryRun };
182
+ }
183
+ if (dryRun) {
184
+ logger.log(`💡 Dry run for ${pushConfig.portalName} (${locale}):\n` +
185
+ Object.keys(translations)
186
+ .map((key) => ` • ${key}`)
187
+ .join("\n"));
188
+ return { pushed: 0, dryRun: true };
189
+ }
190
+ const base = pushConfig.pushUrl.replace(/\/+$/, "");
191
+ const url = `${base}/${encodeURIComponent(locale)}`;
192
+ await sendCatalogPayload({
193
+ url,
194
+ method: "PUT",
195
+ translations,
196
+ pushConfig,
197
+ requestInit,
198
+ });
199
+ logger.log(`✅ Published ${count} ${locale} overrides for ${pushConfig.portalName}.`);
200
+ return { pushed: count, dryRun: false };
201
+ };
package/dist/types.d.ts CHANGED
@@ -21,6 +21,15 @@ export interface FormattersConfig {
21
21
  date?: DateConfig;
22
22
  /** Locale to fall back to when a language code is missing from mappings. */
23
23
  fallbackLocale?: string;
24
+ /**
25
+ * Unicode numbering system applied to all number/date/currency/percent/
26
+ * relative-time output via the locale's `-u-nu-` extension. Defaults to
27
+ * `"latn"` (Western 0-9 digits) so locales like `ar-SA` keep Arabic words
28
+ * (month/weekday names, currency labels) and RTL while rendering Western
29
+ * digits — the common convention for finance/tax UIs. Set to `"arab"` for
30
+ * Eastern Arabic numerals, or `""` to use each locale's native digits.
31
+ */
32
+ numberingSystem?: string;
24
33
  }
25
34
  export interface ReactConfig {
26
35
  useSuspense?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayinza_dev/i18n-config",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",