@blamejs/core 0.7.4 → 0.7.18
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/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/i18n.js
CHANGED
|
@@ -1,878 +1,878 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* b.i18n — translation + locale negotiation primitive.
|
|
4
|
-
*
|
|
5
|
-
* Built on Node 24's bundled `Intl.*` (PluralRules, NumberFormat,
|
|
6
|
-
* DateTimeFormat, RelativeTimeFormat, ListFormat, DisplayNames). Zero
|
|
7
|
-
* vendoring, zero CLDR data shipped — the runtime owns it.
|
|
8
|
-
*
|
|
9
|
-
* var i = b.i18n.create({
|
|
10
|
-
* defaultLocale: "en",
|
|
11
|
-
* locales: ["en", "es", "fr", "ja", "ar"],
|
|
12
|
-
* translations: {
|
|
13
|
-
* en: { greeting: "Hello, {name}!", items: { one: "{count} item", other: "{count} items" } },
|
|
14
|
-
* es: { greeting: "¡Hola, {name}!" },
|
|
15
|
-
* },
|
|
16
|
-
* });
|
|
17
|
-
*
|
|
18
|
-
* i.t("greeting", { name: "Alice" }); // → "Hello, Alice!"
|
|
19
|
-
* i.tn("items", 5); // → "5 items"
|
|
20
|
-
* i.formatNumber(1234.5, { style: "currency", currency: "USD" });
|
|
21
|
-
* i.formatRelative(-5, "minute"); // → "5 minutes ago"
|
|
22
|
-
*
|
|
23
|
-
* router.use(i.middleware());
|
|
24
|
-
* // ...later
|
|
25
|
-
* res.locals.t("greeting", { name: req.user.name });
|
|
26
|
-
*
|
|
27
|
-
* Translation file format (JSON):
|
|
28
|
-
*
|
|
29
|
-
* {
|
|
30
|
-
* "greeting": "Hello, {name}!",
|
|
31
|
-
* "items": { "one": "{count} item", "other": "{count} items" },
|
|
32
|
-
* "nav": { "home": "Home", "about": "About" }
|
|
33
|
-
* }
|
|
34
|
-
*
|
|
35
|
-
* - Plural-shaped values use CLDR cardinal keys (zero/one/two/few/
|
|
36
|
-
* many/other). `other` is mandatory — caught at load.
|
|
37
|
-
* - Nested keys use dotted paths in t() (e.g. "nav.home").
|
|
38
|
-
* - {var} interpolation; missing vars render as literal {var} unless
|
|
39
|
-
* `interpolation.strict: true`.
|
|
40
|
-
*
|
|
41
|
-
* Validation policy:
|
|
42
|
-
*
|
|
43
|
-
* - create() opts → throw at boot
|
|
44
|
-
* - bad locale tag at any boundary → throw at call site
|
|
45
|
-
* - t(missingKey) → return key + observability event
|
|
46
|
-
* - t() with bad locale override → throw at call site (programming bug)
|
|
47
|
-
* - plural shape missing 'other' → throw at load time
|
|
48
|
-
* - interpolation missing var → render literal {var}
|
|
49
|
-
* - format* bad input → throw at call site
|
|
50
|
-
* - middleware Accept-Language parse error → fall back to defaultLocale
|
|
51
|
-
*
|
|
52
|
-
* Security stance: translation values come from operator-controlled
|
|
53
|
-
* files, not user input. {var} interpolation does NOT html-escape;
|
|
54
|
-
* `b.template` already escapes when rendered. Operators using t() in
|
|
55
|
-
* non-template contexts pass `interpolation.escape`.
|
|
56
|
-
*
|
|
57
|
-
* No audit-chain integration: i18n is not operator-action shaped (no
|
|
58
|
-
* state mutation). Routing observability events (missing key, locale
|
|
59
|
-
* fallback, formatter cache misses) is enough.
|
|
60
|
-
*/
|
|
61
|
-
|
|
62
|
-
var fs = require("node:fs");
|
|
63
|
-
var path = require("node:path");
|
|
64
|
-
var lazyRequire = require("./lazy-require");
|
|
65
|
-
var requestHelpers = require("./request-helpers");
|
|
66
|
-
var safeJson = require("./safe-json");
|
|
67
|
-
var validateOpts = require("./validate-opts");
|
|
68
|
-
var { I18nError } = require("./framework-error");
|
|
69
|
-
|
|
70
|
-
var observability = lazyRequire(function () { return require("./observability"); });
|
|
71
|
-
|
|
72
|
-
var _err = I18nError.factory;
|
|
73
|
-
|
|
74
|
-
// CLDR cardinal plural categories. PluralRules.select() returns one of
|
|
75
|
-
// these; translation files declare keys from this set. `other` is
|
|
76
|
-
// mandatory in any plural-shaped translation entry.
|
|
77
|
-
var PLURAL_CATEGORIES = Object.freeze(["zero", "one", "two", "few", "many", "other"]);
|
|
78
|
-
|
|
79
|
-
// BCP 47 language subtags whose default writing direction is RTL.
|
|
80
|
-
// Operators extend by passing rtlLanguages: [...DEFAULTS.RTL_LANGUAGES, "yourLang"]
|
|
81
|
-
// at create time. Sources: Unicode CLDR + W3C i18n recommendations.
|
|
82
|
-
var RTL_LANGUAGES = Object.freeze(new Set([
|
|
83
|
-
"ar", // Arabic
|
|
84
|
-
"fa", // Persian
|
|
85
|
-
"he", // Hebrew
|
|
86
|
-
"ur", // Urdu
|
|
87
|
-
"ps", // Pashto
|
|
88
|
-
"sd", // Sindhi
|
|
89
|
-
"yi", // Yiddish
|
|
90
|
-
"ckb", // Central Kurdish
|
|
91
|
-
"dv", // Divehi
|
|
92
|
-
]));
|
|
93
|
-
|
|
94
|
-
var DEFAULTS = Object.freeze({
|
|
95
|
-
fallbackLocale: null, // resolved at create() time (defaults to defaultLocale)
|
|
96
|
-
missingKey: "return-key",
|
|
97
|
-
interpolation: Object.freeze({ start: "{", end: "}", strict: false }),
|
|
98
|
-
RTL_LANGUAGES: RTL_LANGUAGES,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// ---- Call-site validation (throw on bad input) ----
|
|
102
|
-
|
|
103
|
-
function _isValidBcp47(tag) {
|
|
104
|
-
if (typeof tag !== "string" || tag.length === 0) return false;
|
|
105
|
-
try {
|
|
106
|
-
var canonical = Intl.getCanonicalLocales(tag);
|
|
107
|
-
return canonical.length === 1;
|
|
108
|
-
} catch (_e) {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function _validateLocale(name, value) {
|
|
114
|
-
if (!_isValidBcp47(value)) {
|
|
115
|
-
throw _err("BAD_LOCALE", name + " must be a valid BCP 47 language tag, got " +
|
|
116
|
-
JSON.stringify(value));
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function _validateLocaleArray(name, value) {
|
|
121
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
122
|
-
throw _err("BAD_OPT", name + " must be a non-empty array of BCP 47 tags");
|
|
123
|
-
}
|
|
124
|
-
for (var i = 0; i < value.length; i++) {
|
|
125
|
-
_validateLocale(name + "[" + i + "]", value[i]);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function _validateInterpolation(value) {
|
|
130
|
-
if (value === undefined) return DEFAULTS.interpolation;
|
|
131
|
-
if (typeof value !== "object" || value === null) {
|
|
132
|
-
throw _err("BAD_OPT", "i18n.create: interpolation must be an object");
|
|
133
|
-
}
|
|
134
|
-
var start = value.start !== undefined ? value.start : DEFAULTS.interpolation.start;
|
|
135
|
-
var end = value.end !== undefined ? value.end : DEFAULTS.interpolation.end;
|
|
136
|
-
if (typeof start !== "string" || start.length === 0) {
|
|
137
|
-
throw _err("BAD_OPT", "i18n.create: interpolation.start must be a non-empty string");
|
|
138
|
-
}
|
|
139
|
-
if (typeof end !== "string" || end.length === 0) {
|
|
140
|
-
throw _err("BAD_OPT", "i18n.create: interpolation.end must be a non-empty string");
|
|
141
|
-
}
|
|
142
|
-
if (value.escape !== undefined && typeof value.escape !== "function") {
|
|
143
|
-
throw _err("BAD_OPT", "i18n.create: interpolation.escape must be a function");
|
|
144
|
-
}
|
|
145
|
-
if (value.strict !== undefined && typeof value.strict !== "boolean") {
|
|
146
|
-
throw _err("BAD_OPT", "i18n.create: interpolation.strict must be a boolean");
|
|
147
|
-
}
|
|
148
|
-
return Object.freeze({
|
|
149
|
-
start: start,
|
|
150
|
-
end: end,
|
|
151
|
-
escape: value.escape || null,
|
|
152
|
-
strict: value.strict === true,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function _validateMissingKeyPolicy(value) {
|
|
157
|
-
if (value === undefined) return DEFAULTS.missingKey;
|
|
158
|
-
if (value === "return-key" || value === "throw") return value;
|
|
159
|
-
if (typeof value === "function") return value;
|
|
160
|
-
throw _err("BAD_OPT",
|
|
161
|
-
"i18n.create: missingKey must be 'return-key' / 'throw' / function, got " +
|
|
162
|
-
JSON.stringify(value));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function _validateRtlList(value) {
|
|
166
|
-
if (value === undefined) return RTL_LANGUAGES;
|
|
167
|
-
if (!Array.isArray(value)) {
|
|
168
|
-
throw _err("BAD_OPT", "i18n.create: rtlLanguages must be an array of language subtags");
|
|
169
|
-
}
|
|
170
|
-
for (var i = 0; i < value.length; i++) {
|
|
171
|
-
if (typeof value[i] !== "string" || value[i].length === 0) {
|
|
172
|
-
throw _err("BAD_OPT", "i18n.create: rtlLanguages[" + i + "] must be a non-empty string");
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return new Set(value);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ---- Translation tree validation ----
|
|
179
|
-
|
|
180
|
-
function _isPluralShape(node) {
|
|
181
|
-
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
182
|
-
// A plural-shaped node has ALL string values, AND at least one CLDR key.
|
|
183
|
-
var keys = Object.keys(node);
|
|
184
|
-
if (keys.length === 0) return false;
|
|
185
|
-
var hasCldrKey = false;
|
|
186
|
-
for (var i = 0; i < keys.length; i++) {
|
|
187
|
-
var k = keys[i];
|
|
188
|
-
if (PLURAL_CATEGORIES.indexOf(k) !== -1) hasCldrKey = true;
|
|
189
|
-
if (typeof node[k] !== "string") return false;
|
|
190
|
-
}
|
|
191
|
-
return hasCldrKey;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function _validateTranslationTree(locale, node, dottedPath) {
|
|
195
|
-
if (typeof node === "string") return;
|
|
196
|
-
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
197
|
-
throw _err("BAD_TRANSLATIONS",
|
|
198
|
-
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
199
|
-
"': value must be a string or nested object");
|
|
200
|
-
}
|
|
201
|
-
if (_isPluralShape(node)) {
|
|
202
|
-
if (typeof node.other !== "string") {
|
|
203
|
-
throw _err("BAD_TRANSLATIONS",
|
|
204
|
-
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
205
|
-
"': plural-shaped entries must include an 'other' key (CLDR mandatory)");
|
|
206
|
-
}
|
|
207
|
-
// Reject unknown CLDR keys to catch typos like "ohter"
|
|
208
|
-
var keys = Object.keys(node);
|
|
209
|
-
for (var i = 0; i < keys.length; i++) {
|
|
210
|
-
if (PLURAL_CATEGORIES.indexOf(keys[i]) === -1) {
|
|
211
|
-
throw _err("BAD_TRANSLATIONS",
|
|
212
|
-
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
213
|
-
"' has unknown plural category '" + keys[i] +
|
|
214
|
-
"' (allowed: " + PLURAL_CATEGORIES.join(", ") + ")");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
// Recurse into nested namespace.
|
|
220
|
-
var nestedKeys = Object.keys(node);
|
|
221
|
-
for (var j = 0; j < nestedKeys.length; j++) {
|
|
222
|
-
var k = nestedKeys[j];
|
|
223
|
-
_validateTranslationTree(locale, node[k],
|
|
224
|
-
dottedPath ? (dottedPath + "." + k) : k);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function _loadFromDir(dir, locales) {
|
|
229
|
-
var out = {};
|
|
230
|
-
for (var i = 0; i < locales.length; i++) {
|
|
231
|
-
var locale = locales[i];
|
|
232
|
-
var filePath = path.join(dir, locale + ".json");
|
|
233
|
-
if (!fs.existsSync(filePath)) {
|
|
234
|
-
throw _err("LOAD_FAILED",
|
|
235
|
-
"i18n: translations file not found for locale '" + locale + "': " + filePath);
|
|
236
|
-
}
|
|
237
|
-
var raw;
|
|
238
|
-
try { raw = fs.readFileSync(filePath, "utf8"); }
|
|
239
|
-
catch (e) {
|
|
240
|
-
throw _err("LOAD_FAILED",
|
|
241
|
-
"i18n: failed to read '" + filePath + "': " + ((e && e.message) || String(e)));
|
|
242
|
-
}
|
|
243
|
-
try { out[locale] = safeJson.parse(raw); }
|
|
244
|
-
catch (e) {
|
|
245
|
-
throw _err("LOAD_FAILED",
|
|
246
|
-
"i18n: failed to parse JSON for locale '" + locale + "' (" + filePath + "): " +
|
|
247
|
-
((e && e.message) || String(e)));
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return out;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ---- Validation: create opts ----
|
|
254
|
-
|
|
255
|
-
function _validateCreateOpts(opts) {
|
|
256
|
-
validateOpts.requireObject(opts, "i18n.create", I18nError);
|
|
257
|
-
_validateLocale("i18n.create: defaultLocale", opts.defaultLocale);
|
|
258
|
-
_validateLocaleArray("i18n.create: locales", opts.locales);
|
|
259
|
-
if (opts.locales.indexOf(opts.defaultLocale) === -1) {
|
|
260
|
-
throw _err("BAD_OPT",
|
|
261
|
-
"i18n.create: defaultLocale '" + opts.defaultLocale +
|
|
262
|
-
"' must be present in locales array");
|
|
263
|
-
}
|
|
264
|
-
if (opts.fallbackLocale !== undefined && opts.fallbackLocale !== null) {
|
|
265
|
-
_validateLocale("i18n.create: fallbackLocale", opts.fallbackLocale);
|
|
266
|
-
if (opts.locales.indexOf(opts.fallbackLocale) === -1) {
|
|
267
|
-
throw _err("BAD_OPT",
|
|
268
|
-
"i18n.create: fallbackLocale '" + opts.fallbackLocale +
|
|
269
|
-
"' must be present in locales array");
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (opts.translations !== undefined && opts.dir !== undefined) {
|
|
273
|
-
throw _err("BAD_OPT",
|
|
274
|
-
"i18n.create: pass either translations OR dir, not both");
|
|
275
|
-
}
|
|
276
|
-
if (opts.translations !== undefined) {
|
|
277
|
-
if (typeof opts.translations !== "object" || opts.translations === null || Array.isArray(opts.translations)) {
|
|
278
|
-
throw _err("BAD_OPT", "i18n.create: translations must be an object keyed by locale");
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
validateOpts.optionalNonEmptyString(opts.dir, "i18n.create: dir", I18nError);
|
|
282
|
-
validateOpts.observabilityShape(opts.observability, "i18n.create", I18nError);
|
|
283
|
-
validateOpts.optionalFunction(opts.clock, "i18n.create: clock", I18nError);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ---- Dotted-path resolution ----
|
|
287
|
-
|
|
288
|
-
function _resolveKey(tree, dottedKey) {
|
|
289
|
-
if (!tree || typeof tree !== "object") return undefined;
|
|
290
|
-
if (typeof dottedKey !== "string" || dottedKey.length === 0) return undefined;
|
|
291
|
-
// Fast path: no dots, direct lookup.
|
|
292
|
-
if (dottedKey.indexOf(".") === -1) return tree[dottedKey];
|
|
293
|
-
var parts = dottedKey.split(".");
|
|
294
|
-
var node = tree;
|
|
295
|
-
for (var i = 0; i < parts.length; i++) {
|
|
296
|
-
if (!node || typeof node !== "object") return undefined;
|
|
297
|
-
node = node[parts[i]];
|
|
298
|
-
if (node === undefined) return undefined;
|
|
299
|
-
}
|
|
300
|
-
return node;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ---- Interpolation ----
|
|
304
|
-
// Replace {var} placeholders with values from the vars object. Missing
|
|
305
|
-
// vars render as literal `{var}` unless interpolation.strict is true.
|
|
306
|
-
function _interpolate(template, vars, interpolation) {
|
|
307
|
-
if (!template || typeof template !== "string") return template;
|
|
308
|
-
// Use an empty object when caller passed nothing — strict mode still
|
|
309
|
-
// needs to walk the template so missing placeholders surface.
|
|
310
|
-
if (!vars || typeof vars !== "object") vars = {};
|
|
311
|
-
var start = interpolation.start;
|
|
312
|
-
var end = interpolation.end;
|
|
313
|
-
var escape = interpolation.escape;
|
|
314
|
-
var out = "";
|
|
315
|
-
var i = 0;
|
|
316
|
-
while (i < template.length) {
|
|
317
|
-
var openIdx = template.indexOf(start, i);
|
|
318
|
-
if (openIdx === -1) {
|
|
319
|
-
out += template.slice(i);
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
out += template.slice(i, openIdx);
|
|
323
|
-
var closeIdx = template.indexOf(end, openIdx + start.length);
|
|
324
|
-
if (closeIdx === -1) {
|
|
325
|
-
// Unclosed delimiter — treat as literal text from openIdx to end.
|
|
326
|
-
out += template.slice(openIdx);
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
var name = template.slice(openIdx + start.length, closeIdx).trim();
|
|
330
|
-
var hasVar = Object.prototype.hasOwnProperty.call(vars, name) && vars[name] !== undefined;
|
|
331
|
-
if (hasVar) {
|
|
332
|
-
var value = vars[name];
|
|
333
|
-
// null renders as empty string (operator passed null intentionally);
|
|
334
|
-
// undefined falls through to "missing" so devs see {var} surfacing
|
|
335
|
-
// and don't silently get blank UI from a typo'd variable.
|
|
336
|
-
var stringified = (value === null) ? "" : String(value);
|
|
337
|
-
out += escape ? escape(stringified) : stringified;
|
|
338
|
-
} else {
|
|
339
|
-
if (interpolation.strict) {
|
|
340
|
-
throw _err("MISSING_VAR",
|
|
341
|
-
"i18n: missing interpolation var '" + name + "' in '" + template + "'");
|
|
342
|
-
}
|
|
343
|
-
out += template.slice(openIdx, closeIdx + end.length);
|
|
344
|
-
}
|
|
345
|
-
i = closeIdx + end.length;
|
|
346
|
-
}
|
|
347
|
-
return out;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// ---- Formatter caches ----
|
|
351
|
-
// `Intl.*` instances are expensive enough that allocating a fresh one
|
|
352
|
-
// per format() call shows up in perf traces under load. Cache by
|
|
353
|
-
// (locale, options-hash). Hash is JSON.stringify of the formatOpts —
|
|
354
|
-
// stable across object-literal calls and small enough we'd see drift
|
|
355
|
-
// only with operators handing in fresh literals every call (they
|
|
356
|
-
// usually pass the same shape).
|
|
357
|
-
function _makeFormatterCache(make, kind, emitObs) {
|
|
358
|
-
var cache = new Map();
|
|
359
|
-
return function getFormatter(locale, formatOpts) {
|
|
360
|
-
var optsKey = formatOpts ? JSON.stringify(formatOpts) : "";
|
|
361
|
-
var cacheKey = locale + "\x1f" + optsKey;
|
|
362
|
-
var f = cache.get(cacheKey);
|
|
363
|
-
if (!f) {
|
|
364
|
-
f = make(locale, formatOpts);
|
|
365
|
-
cache.set(cacheKey, f);
|
|
366
|
-
emitObs("i18n.format.created", { kind: kind, locale: locale });
|
|
367
|
-
}
|
|
368
|
-
return f;
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ---- Public create ----
|
|
373
|
-
|
|
374
|
-
function create(opts) {
|
|
375
|
-
opts = opts || {};
|
|
376
|
-
validateOpts(opts, [
|
|
377
|
-
"defaultLocale", "locales", "fallbackLocale",
|
|
378
|
-
"translations", "dir", "eagerLocales", "lazyLoad",
|
|
379
|
-
"interpolation", "missingKey", "onMissingKey", "rtlLanguages",
|
|
380
|
-
"observability", "clock",
|
|
381
|
-
], "b.i18n");
|
|
382
|
-
_validateCreateOpts(opts);
|
|
383
|
-
|
|
384
|
-
if (opts.lazyLoad === true && opts.translations) {
|
|
385
|
-
throw _err("BAD_OPT", "i18n.create: lazyLoad: true requires dir-based loading; " +
|
|
386
|
-
"translations: { ... } is inline-only and already complete at create time");
|
|
387
|
-
}
|
|
388
|
-
if (opts.eagerLocales !== undefined) {
|
|
389
|
-
if (!Array.isArray(opts.eagerLocales)) {
|
|
390
|
-
throw _err("BAD_OPT", "i18n.create: eagerLocales must be an array of BCP 47 tags");
|
|
391
|
-
}
|
|
392
|
-
for (var ei = 0; ei < opts.eagerLocales.length; ei++) {
|
|
393
|
-
_validateLocale("i18n.create: eagerLocales[" + ei + "]", opts.eagerLocales[ei]);
|
|
394
|
-
if (opts.locales.indexOf(opts.eagerLocales[ei]) === -1) {
|
|
395
|
-
throw _err("BAD_OPT", "i18n.create: eagerLocales[" + ei + "] '" +
|
|
396
|
-
opts.eagerLocales[ei] + "' must be in locales array");
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
if (opts.onMissingKey !== undefined && typeof opts.onMissingKey !== "function") {
|
|
401
|
-
throw _err("BAD_OPT", "i18n.create: onMissingKey must be a function (key, locale)");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
var defaultLocale = opts.defaultLocale;
|
|
405
|
-
var locales = opts.locales.slice();
|
|
406
|
-
var fallbackLocale = (opts.fallbackLocale === null) ? null
|
|
407
|
-
: ((opts.fallbackLocale === undefined) ? defaultLocale : opts.fallbackLocale);
|
|
408
|
-
var interpolation = _validateInterpolation(opts.interpolation);
|
|
409
|
-
var missingKeyPolicy = _validateMissingKeyPolicy(opts.missingKey);
|
|
410
|
-
var rtlSet = _validateRtlList(opts.rtlLanguages);
|
|
411
|
-
var operatorObs = opts.observability || null;
|
|
412
|
-
|
|
413
|
-
// Translations: either inline object or loaded from dir at create.
|
|
414
|
-
// With lazyLoad, only eager locales hit disk now; the rest load on
|
|
415
|
-
// first lookup that resolves to them.
|
|
416
|
-
var translations;
|
|
417
|
-
var lazyLoadEnabled = false;
|
|
418
|
-
var lazyLoadDir = null;
|
|
419
|
-
var loadedSet = new Set();
|
|
420
|
-
if (opts.dir) {
|
|
421
|
-
if (opts.lazyLoad === true) {
|
|
422
|
-
lazyLoadEnabled = true;
|
|
423
|
-
lazyLoadDir = opts.dir;
|
|
424
|
-
var eager = Array.isArray(opts.eagerLocales) && opts.eagerLocales.length > 0
|
|
425
|
-
? opts.eagerLocales
|
|
426
|
-
: [defaultLocale];
|
|
427
|
-
translations = _loadFromDir(opts.dir, eager);
|
|
428
|
-
for (var ei2 = 0; ei2 < eager.length; ei2++) loadedSet.add(eager[ei2]);
|
|
429
|
-
} else {
|
|
430
|
-
translations = _loadFromDir(opts.dir, locales);
|
|
431
|
-
for (var ei3 = 0; ei3 < locales.length; ei3++) loadedSet.add(locales[ei3]);
|
|
432
|
-
}
|
|
433
|
-
} else if (opts.translations) {
|
|
434
|
-
translations = opts.translations;
|
|
435
|
-
for (var ei4 = 0; ei4 < locales.length; ei4++) {
|
|
436
|
-
if (translations[locales[ei4]]) loadedSet.add(locales[ei4]);
|
|
437
|
-
}
|
|
438
|
-
} else {
|
|
439
|
-
translations = {};
|
|
440
|
-
}
|
|
441
|
-
var onMissingKey = opts.onMissingKey || null;
|
|
442
|
-
// Validate translation trees up-front so plural-shape errors surface
|
|
443
|
-
// at boot, not at the first request that hits the broken key. Lazy
|
|
444
|
-
// locales validate on first load.
|
|
445
|
-
for (var li = 0; li < locales.length; li++) {
|
|
446
|
-
var loc = locales[li];
|
|
447
|
-
if (translations[loc]) {
|
|
448
|
-
_validateTranslationTree(loc, translations[loc], "");
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function _ensureLocaleLoaded(locale) {
|
|
453
|
-
if (loadedSet.has(locale)) return;
|
|
454
|
-
if (!lazyLoadEnabled || !lazyLoadDir) return; // not configured for lazy
|
|
455
|
-
if (!localesSet.has(locale)) return; // unknown locale; lookup falls through
|
|
456
|
-
var loaded = _loadFromDir(lazyLoadDir, [locale]);
|
|
457
|
-
translations[locale] = loaded[locale];
|
|
458
|
-
_validateTranslationTree(locale, translations[locale], "");
|
|
459
|
-
loadedSet.add(locale);
|
|
460
|
-
_emitObs("i18n.lazyLoad", { locale: locale });
|
|
461
|
-
}
|
|
462
|
-
var localesSet = new Set(locales);
|
|
463
|
-
var currentLocale = defaultLocale;
|
|
464
|
-
|
|
465
|
-
function _emitObs(name, labels) {
|
|
466
|
-
try {
|
|
467
|
-
if (operatorObs) operatorObs.event(name, 1, labels || {});
|
|
468
|
-
else observability().event(name, 1, labels || {});
|
|
469
|
-
} catch (_e) { /* drop-silent — observability sink must not crash i18n calls */ }
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Cardinal plural-rules instances per locale. `Intl.PluralRules` is
|
|
473
|
-
// the modern replacement for gettext's plural-forms pragma.
|
|
474
|
-
var pluralRulesByLocale = {};
|
|
475
|
-
function _pluralRulesFor(locale) {
|
|
476
|
-
var r = pluralRulesByLocale[locale];
|
|
477
|
-
if (!r) {
|
|
478
|
-
r = new Intl.PluralRules(locale);
|
|
479
|
-
pluralRulesByLocale[locale] = r;
|
|
480
|
-
}
|
|
481
|
-
return r;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Per-kind formatter caches.
|
|
485
|
-
var numberFormatter = _makeFormatterCache(
|
|
486
|
-
function (locale, fopts) { return new Intl.NumberFormat(locale, fopts || undefined); },
|
|
487
|
-
"number", _emitObs);
|
|
488
|
-
var dateFormatter = _makeFormatterCache(
|
|
489
|
-
function (locale, fopts) { return new Intl.DateTimeFormat(locale, fopts || undefined); },
|
|
490
|
-
"date", _emitObs);
|
|
491
|
-
var relativeFormatter = _makeFormatterCache(
|
|
492
|
-
function (locale, fopts) { return new Intl.RelativeTimeFormat(locale, fopts || undefined); },
|
|
493
|
-
"relative", _emitObs);
|
|
494
|
-
var listFormatter = _makeFormatterCache(
|
|
495
|
-
function (locale, fopts) { return new Intl.ListFormat(locale, fopts || undefined); },
|
|
496
|
-
"list", _emitObs);
|
|
497
|
-
var displayNamesFormatter = _makeFormatterCache(
|
|
498
|
-
function (locale, fopts) { return new Intl.DisplayNames([locale], fopts || { type: "language" }); },
|
|
499
|
-
"displayNames", _emitObs);
|
|
500
|
-
|
|
501
|
-
function _resolveLocale(callerLocale) {
|
|
502
|
-
if (callerLocale === undefined || callerLocale === null) return currentLocale;
|
|
503
|
-
if (typeof callerLocale !== "string") {
|
|
504
|
-
throw _err("BAD_LOCALE", "i18n: locale override must be a string");
|
|
505
|
-
}
|
|
506
|
-
if (!_isValidBcp47(callerLocale)) {
|
|
507
|
-
throw _err("BAD_LOCALE", "i18n: locale '" + callerLocale + "' is not a valid BCP 47 tag");
|
|
508
|
-
}
|
|
509
|
-
return callerLocale;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function _localeChain(locale) {
|
|
513
|
-
// Build the resolution chain. Start with the requested locale and
|
|
514
|
-
// strip subtag suffixes (`pt-BR` → `pt`); subtag stripping always
|
|
515
|
-
// applies because it's "same language, less specific" rather than a
|
|
516
|
-
// cross-locale jump. Cross-locale fallback (to fallbackLocale, then
|
|
517
|
-
// defaultLocale) only fires when fallbackLocale is non-null —
|
|
518
|
-
// operators who set fallbackLocale: null get strict "this locale or
|
|
519
|
-
// miss" semantics.
|
|
520
|
-
var chain = [];
|
|
521
|
-
var current = locale;
|
|
522
|
-
while (current && chain.indexOf(current) === -1) {
|
|
523
|
-
chain.push(current);
|
|
524
|
-
var dash = current.lastIndexOf("-");
|
|
525
|
-
if (dash === -1) break;
|
|
526
|
-
current = current.slice(0, dash);
|
|
527
|
-
}
|
|
528
|
-
if (fallbackLocale === null) return chain;
|
|
529
|
-
if (chain.indexOf(fallbackLocale) === -1) chain.push(fallbackLocale);
|
|
530
|
-
if (chain.indexOf(defaultLocale) === -1) chain.push(defaultLocale);
|
|
531
|
-
return chain;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function _lookupRaw(key, locale) {
|
|
535
|
-
var chain = _localeChain(locale);
|
|
536
|
-
for (var i = 0; i < chain.length; i++) {
|
|
537
|
-
var loc = chain[i];
|
|
538
|
-
_ensureLocaleLoaded(loc);
|
|
539
|
-
if (!translations[loc]) continue;
|
|
540
|
-
var v = _resolveKey(translations[loc], key);
|
|
541
|
-
if (v !== undefined) {
|
|
542
|
-
return { value: v, foundIn: loc };
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Ordinal-plural rules cache — separate from cardinal because Intl.PluralRules
|
|
549
|
-
// is type-fixed at construction.
|
|
550
|
-
var ordinalRulesByLocale = {};
|
|
551
|
-
function _ordinalRulesFor(locale) {
|
|
552
|
-
var r = ordinalRulesByLocale[locale];
|
|
553
|
-
if (!r) {
|
|
554
|
-
r = new Intl.PluralRules(locale, { type: "ordinal" });
|
|
555
|
-
ordinalRulesByLocale[locale] = r;
|
|
556
|
-
}
|
|
557
|
-
return r;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function _selectPlural(node, count, locale, ordinal) {
|
|
561
|
-
var rules = ordinal ? _ordinalRulesFor(locale) : _pluralRulesFor(locale);
|
|
562
|
-
var category = rules.select(count);
|
|
563
|
-
if (typeof node[category] === "string") return node[category];
|
|
564
|
-
// Fallback within the entry: "other" was validated mandatory at load.
|
|
565
|
-
return node.other;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function t(key, vars, callerOpts) {
|
|
569
|
-
if (typeof key !== "string" || key.length === 0) {
|
|
570
|
-
throw _err("BAD_KEY", "i18n.t: key must be a non-empty string, got " + typeof key);
|
|
571
|
-
}
|
|
572
|
-
callerOpts = callerOpts || {};
|
|
573
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
574
|
-
var found = _lookupRaw(key, locale);
|
|
575
|
-
|
|
576
|
-
if (!found) {
|
|
577
|
-
_emitObs("i18n.missing", { locale: locale, key: key });
|
|
578
|
-
if (onMissingKey) {
|
|
579
|
-
try { onMissingKey(key, locale); }
|
|
580
|
-
catch (_e) { /* hook is best-effort; never break the request */ }
|
|
581
|
-
}
|
|
582
|
-
if (callerOpts.default !== undefined) return callerOpts.default;
|
|
583
|
-
if (typeof missingKeyPolicy === "function") {
|
|
584
|
-
return missingKeyPolicy(key, locale);
|
|
585
|
-
}
|
|
586
|
-
if (missingKeyPolicy === "throw") {
|
|
587
|
-
throw _err("MISSING_KEY",
|
|
588
|
-
"i18n.t: key '" + key + "' missing in locale chain for '" + locale + "'");
|
|
589
|
-
}
|
|
590
|
-
return key;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (found.foundIn !== locale) {
|
|
594
|
-
_emitObs("i18n.miss.fallback", { locale: locale, key: key });
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
var raw;
|
|
598
|
-
if (typeof found.value === "string") {
|
|
599
|
-
raw = found.value;
|
|
600
|
-
} else if (_isPluralShape(found.value)) {
|
|
601
|
-
var count = (vars && typeof vars.count === "number") ? vars.count : 0;
|
|
602
|
-
raw = _selectPlural(found.value, count, found.foundIn, callerOpts.ordinal === true);
|
|
603
|
-
} else {
|
|
604
|
-
// Operator stored a nested tree at this key but called t() against
|
|
605
|
-
// the namespace. Return the key-path as a missing-key signal.
|
|
606
|
-
_emitObs("i18n.missing", { locale: locale, key: key });
|
|
607
|
-
return key;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ICU MessageFormat path — when the operator opts in via
|
|
611
|
-
// `messageFormat: true` OR the entry contains a `{name, plural,
|
|
612
|
-
// ...}` / `{name, select, ...}` / `{name, selectordinal, ...}`
|
|
613
|
-
// shape, evaluate via the parser. Otherwise fall back to the
|
|
614
|
-
// simple `{var}` interpolator (existing behaviour, unchanged).
|
|
615
|
-
var useMf = callerOpts.messageFormat === true ||
|
|
616
|
-
messageFormat.looksLikeMessageFormat(raw);
|
|
617
|
-
if (useMf) {
|
|
618
|
-
return messageFormat.format(raw, vars, found.foundIn);
|
|
619
|
-
}
|
|
620
|
-
return _interpolate(raw, vars, interpolation);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function tn(key, count, vars, callerOpts) {
|
|
624
|
-
if (typeof count !== "number" || !isFinite(count)) {
|
|
625
|
-
throw _err("BAD_INPUT", "i18n.tn: count must be a finite number, got " +
|
|
626
|
-
(typeof count) + " " + JSON.stringify(count));
|
|
627
|
-
}
|
|
628
|
-
var merged = vars ? Object.assign({}, vars, { count: count }) : { count: count };
|
|
629
|
-
return t(key, merged, callerOpts);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// to — ordinal-plural counterpart of tn. Selects from the entry using
|
|
633
|
-
// Intl.PluralRules({ type: "ordinal" }), so English keys
|
|
634
|
-
// { one: "{count}st", two: "{count}nd", few: "{count}rd", other: "{count}th" }
|
|
635
|
-
// resolve as "1st", "2nd", "3rd", "4th", "21st", etc.
|
|
636
|
-
function to(key, count, vars, callerOpts) {
|
|
637
|
-
if (typeof count !== "number" || !isFinite(count)) {
|
|
638
|
-
throw _err("BAD_INPUT", "i18n.to: count must be a finite number, got " +
|
|
639
|
-
(typeof count) + " " + JSON.stringify(count));
|
|
640
|
-
}
|
|
641
|
-
var merged = vars ? Object.assign({}, vars, { count: count }) : { count: count };
|
|
642
|
-
var withOrdinal = Object.assign({}, callerOpts || {}, { ordinal: true });
|
|
643
|
-
return t(key, merged, withOrdinal);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function has(key, callerOpts) {
|
|
647
|
-
callerOpts = callerOpts || {};
|
|
648
|
-
if (typeof key !== "string" || key.length === 0) return false;
|
|
649
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
650
|
-
var found = _lookupRaw(key, locale);
|
|
651
|
-
if (found === null) return false;
|
|
652
|
-
// A nested namespace object (not plural-shaped) is NOT a resolvable
|
|
653
|
-
// translation value — has() should report false so callers can gate
|
|
654
|
-
// "show this UI block only if translated" on leaf entries.
|
|
655
|
-
if (typeof found.value === "string") return true;
|
|
656
|
-
if (_isPluralShape(found.value)) return true;
|
|
657
|
-
return false;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function formatNumber(value, formatOpts, callerOpts) {
|
|
661
|
-
if (typeof value !== "number" || !isFinite(value)) {
|
|
662
|
-
throw _err("BAD_INPUT", "i18n.formatNumber: value must be a finite number, got " +
|
|
663
|
-
(typeof value) + " " + JSON.stringify(value));
|
|
664
|
-
}
|
|
665
|
-
callerOpts = callerOpts || {};
|
|
666
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
667
|
-
return numberFormatter(locale, formatOpts).format(value);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function formatDate(value, formatOpts, callerOpts) {
|
|
671
|
-
var d = (value instanceof Date) ? value
|
|
672
|
-
: (typeof value === "number" || typeof value === "string") ? new Date(value)
|
|
673
|
-
: null;
|
|
674
|
-
if (!d || isNaN(d.getTime())) {
|
|
675
|
-
throw _err("BAD_INPUT", "i18n.formatDate: value must be a Date / number / parseable string");
|
|
676
|
-
}
|
|
677
|
-
callerOpts = callerOpts || {};
|
|
678
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
679
|
-
return dateFormatter(locale, formatOpts).format(d);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function formatRelative(value, unit, callerOpts) {
|
|
683
|
-
if (typeof value !== "number" || !isFinite(value)) {
|
|
684
|
-
throw _err("BAD_INPUT", "i18n.formatRelative: value must be a finite number");
|
|
685
|
-
}
|
|
686
|
-
if (typeof unit !== "string" || unit.length === 0) {
|
|
687
|
-
throw _err("BAD_INPUT", "i18n.formatRelative: unit must be a non-empty string");
|
|
688
|
-
}
|
|
689
|
-
callerOpts = callerOpts || {};
|
|
690
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
691
|
-
var fopts = callerOpts.numeric ? { numeric: callerOpts.numeric } : undefined;
|
|
692
|
-
return relativeFormatter(locale, fopts).format(value, unit);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function formatList(items, formatOpts, callerOpts) {
|
|
696
|
-
if (!Array.isArray(items)) {
|
|
697
|
-
throw _err("BAD_INPUT", "i18n.formatList: items must be an array of strings");
|
|
698
|
-
}
|
|
699
|
-
callerOpts = callerOpts || {};
|
|
700
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
701
|
-
return listFormatter(locale, formatOpts).format(items);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function displayName(code, type, callerOpts) {
|
|
705
|
-
if (typeof code !== "string" || code.length === 0) {
|
|
706
|
-
throw _err("BAD_INPUT", "i18n.displayName: code must be a non-empty string");
|
|
707
|
-
}
|
|
708
|
-
var allowed = ["language", "region", "currency", "script"];
|
|
709
|
-
if (allowed.indexOf(type) === -1) {
|
|
710
|
-
throw _err("BAD_INPUT", "i18n.displayName: type must be one of " + allowed.join(", "));
|
|
711
|
-
}
|
|
712
|
-
callerOpts = callerOpts || {};
|
|
713
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
714
|
-
var f = displayNamesFormatter(locale, { type: type });
|
|
715
|
-
return f.of(code);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function setLocale(newLocale) {
|
|
719
|
-
_validateLocale("i18n.setLocale", newLocale);
|
|
720
|
-
if (!localesSet.has(newLocale)) {
|
|
721
|
-
// Permit setting a non-configured locale (operators may want to
|
|
722
|
-
// experiment), but fall the chain through to the configured ones.
|
|
723
|
-
// Don't throw — i18n.locale is set/observed in many flows; making
|
|
724
|
-
// this throw would force operators into try/catch around UI setters.
|
|
725
|
-
_emitObs("i18n.miss.locale", { requested: newLocale, resolved: defaultLocale });
|
|
726
|
-
}
|
|
727
|
-
currentLocale = newLocale;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function dir(callerOpts) {
|
|
731
|
-
callerOpts = callerOpts || {};
|
|
732
|
-
var locale = _resolveLocale(callerOpts.locale);
|
|
733
|
-
var primary = locale.split("-")[0].toLowerCase();
|
|
734
|
-
return rtlSet.has(primary) ? "rtl" : "ltr";
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function getTranslationsFor(locale) {
|
|
738
|
-
if (typeof locale !== "string" || locale.length === 0) {
|
|
739
|
-
throw _err("BAD_INPUT", "i18n.translations: locale must be a string");
|
|
740
|
-
}
|
|
741
|
-
return translations[locale] || null;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ---- Locale negotiation (Accept-Language) ----
|
|
745
|
-
// Find the best match among configured locales using the parsed
|
|
746
|
-
// q-list. Longest-prefix wins per RFC 9110 §12.5.4.
|
|
747
|
-
function _negotiateLocale(parsedList) {
|
|
748
|
-
for (var i = 0; i < parsedList.length; i++) {
|
|
749
|
-
var requested = parsedList[i].value;
|
|
750
|
-
if (parsedList[i].q === 0) continue;
|
|
751
|
-
// Direct hit
|
|
752
|
-
if (localesSet.has(requested)) return requested;
|
|
753
|
-
// Prefix match: requested "pt-BR" → configured "pt"
|
|
754
|
-
var dash = requested.lastIndexOf("-");
|
|
755
|
-
while (dash !== -1) {
|
|
756
|
-
var prefix = requested.slice(0, dash);
|
|
757
|
-
if (localesSet.has(prefix)) return prefix;
|
|
758
|
-
dash = prefix.lastIndexOf("-");
|
|
759
|
-
}
|
|
760
|
-
// Reverse: requested "pt" → configured "pt-BR" (broaden — first match)
|
|
761
|
-
for (var j = 0; j < locales.length; j++) {
|
|
762
|
-
var loc = locales[j];
|
|
763
|
-
if (loc.toLowerCase().split("-")[0] === requested.toLowerCase().split("-")[0]) {
|
|
764
|
-
return loc;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
return defaultLocale;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function middleware(mwOpts) {
|
|
772
|
-
mwOpts = mwOpts || {};
|
|
773
|
-
var headerName = (mwOpts.headerName || "accept-language").toLowerCase();
|
|
774
|
-
var queryParam = mwOpts.queryParam || "lang";
|
|
775
|
-
var cookieName = mwOpts.cookieName || null; // operator opt-in
|
|
776
|
-
var resolver = typeof mwOpts.resolver === "function" ? mwOpts.resolver : null;
|
|
777
|
-
|
|
778
|
-
return function i18nMiddleware(req, res, next) {
|
|
779
|
-
try {
|
|
780
|
-
var explicit = null;
|
|
781
|
-
if (resolver) {
|
|
782
|
-
try { explicit = resolver(req); }
|
|
783
|
-
catch (_e) { explicit = null; }
|
|
784
|
-
}
|
|
785
|
-
if (!explicit && req.query && typeof req.query[queryParam] === "string") {
|
|
786
|
-
explicit = req.query[queryParam];
|
|
787
|
-
}
|
|
788
|
-
if (!explicit && cookieName && req.cookies && typeof req.cookies[cookieName] === "string") {
|
|
789
|
-
explicit = req.cookies[cookieName];
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
var resolvedLocale;
|
|
793
|
-
if (explicit && _isValidBcp47(explicit) && localesSet.has(explicit)) {
|
|
794
|
-
resolvedLocale = explicit;
|
|
795
|
-
} else {
|
|
796
|
-
var headerValue = (req.headers && req.headers[headerName]) || null;
|
|
797
|
-
var parsed = requestHelpers.parseQualityList(headerValue);
|
|
798
|
-
resolvedLocale = _negotiateLocale(parsed);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Per-request bound t / dir so handlers can call without
|
|
802
|
-
// threading locale through every site.
|
|
803
|
-
function reqT(key, vars, callerOpts) {
|
|
804
|
-
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
805
|
-
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
806
|
-
return t(key, vars, c);
|
|
807
|
-
}
|
|
808
|
-
function reqTn(key, count, vars, callerOpts) {
|
|
809
|
-
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
810
|
-
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
811
|
-
return tn(key, count, vars, c);
|
|
812
|
-
}
|
|
813
|
-
function reqTo(key, count, vars, callerOpts) {
|
|
814
|
-
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
815
|
-
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
816
|
-
return to(key, count, vars, c);
|
|
817
|
-
}
|
|
818
|
-
function reqDir() {
|
|
819
|
-
return dir({ locale: resolvedLocale });
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
req.locale = resolvedLocale;
|
|
823
|
-
req.t = reqT;
|
|
824
|
-
req.tn = reqTn;
|
|
825
|
-
req.to = reqTo;
|
|
826
|
-
req.dir = reqDir;
|
|
827
|
-
if (res && typeof res === "object") {
|
|
828
|
-
if (!res.locals) res.locals = {};
|
|
829
|
-
res.locals.locale = resolvedLocale;
|
|
830
|
-
res.locals.t = reqT;
|
|
831
|
-
res.locals.tn = reqTn;
|
|
832
|
-
res.locals.to = reqTo;
|
|
833
|
-
res.locals.dir = reqDir();
|
|
834
|
-
}
|
|
835
|
-
} catch (_e) {
|
|
836
|
-
// Hot path — never crash the request because of i18n header parsing.
|
|
837
|
-
req.locale = currentLocale;
|
|
838
|
-
}
|
|
839
|
-
next();
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
return {
|
|
844
|
-
t: t,
|
|
845
|
-
tn: tn,
|
|
846
|
-
to: to,
|
|
847
|
-
has: has,
|
|
848
|
-
formatNumber: formatNumber,
|
|
849
|
-
formatDate: formatDate,
|
|
850
|
-
formatRelative: formatRelative,
|
|
851
|
-
formatList: formatList,
|
|
852
|
-
displayName: displayName,
|
|
853
|
-
setLocale: setLocale,
|
|
854
|
-
dir: dir,
|
|
855
|
-
locales: function () { return locales.slice(); },
|
|
856
|
-
translations: getTranslationsFor,
|
|
857
|
-
middleware: middleware,
|
|
858
|
-
// Property getter so `i.locale` reflects setLocale changes.
|
|
859
|
-
get locale() { return currentLocale; },
|
|
860
|
-
// Test hooks
|
|
861
|
-
_localeChain: _localeChain,
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// ICU MessageFormat companion — top-level namespace so operators can
|
|
866
|
-
// pre-format strings outside the i18n instance (build pipeline, audit
|
|
867
|
-
// formatters, etc.). The instance returned by `create()` plumbs it
|
|
868
|
-
// through `t(key, vars, { messageFormat: true })`.
|
|
869
|
-
var messageFormat = require("./i18n-messageformat");
|
|
870
|
-
|
|
871
|
-
module.exports = {
|
|
872
|
-
create: create,
|
|
873
|
-
messageFormat: messageFormat,
|
|
874
|
-
I18nError: I18nError,
|
|
875
|
-
DEFAULTS: DEFAULTS,
|
|
876
|
-
RTL_LANGUAGES: RTL_LANGUAGES,
|
|
877
|
-
PLURAL_CATEGORIES: PLURAL_CATEGORIES,
|
|
878
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.i18n — translation + locale negotiation primitive.
|
|
4
|
+
*
|
|
5
|
+
* Built on Node 24's bundled `Intl.*` (PluralRules, NumberFormat,
|
|
6
|
+
* DateTimeFormat, RelativeTimeFormat, ListFormat, DisplayNames). Zero
|
|
7
|
+
* vendoring, zero CLDR data shipped — the runtime owns it.
|
|
8
|
+
*
|
|
9
|
+
* var i = b.i18n.create({
|
|
10
|
+
* defaultLocale: "en",
|
|
11
|
+
* locales: ["en", "es", "fr", "ja", "ar"],
|
|
12
|
+
* translations: {
|
|
13
|
+
* en: { greeting: "Hello, {name}!", items: { one: "{count} item", other: "{count} items" } },
|
|
14
|
+
* es: { greeting: "¡Hola, {name}!" },
|
|
15
|
+
* },
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* i.t("greeting", { name: "Alice" }); // → "Hello, Alice!"
|
|
19
|
+
* i.tn("items", 5); // → "5 items"
|
|
20
|
+
* i.formatNumber(1234.5, { style: "currency", currency: "USD" });
|
|
21
|
+
* i.formatRelative(-5, "minute"); // → "5 minutes ago"
|
|
22
|
+
*
|
|
23
|
+
* router.use(i.middleware());
|
|
24
|
+
* // ...later
|
|
25
|
+
* res.locals.t("greeting", { name: req.user.name });
|
|
26
|
+
*
|
|
27
|
+
* Translation file format (JSON):
|
|
28
|
+
*
|
|
29
|
+
* {
|
|
30
|
+
* "greeting": "Hello, {name}!",
|
|
31
|
+
* "items": { "one": "{count} item", "other": "{count} items" },
|
|
32
|
+
* "nav": { "home": "Home", "about": "About" }
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* - Plural-shaped values use CLDR cardinal keys (zero/one/two/few/
|
|
36
|
+
* many/other). `other` is mandatory — caught at load.
|
|
37
|
+
* - Nested keys use dotted paths in t() (e.g. "nav.home").
|
|
38
|
+
* - {var} interpolation; missing vars render as literal {var} unless
|
|
39
|
+
* `interpolation.strict: true`.
|
|
40
|
+
*
|
|
41
|
+
* Validation policy:
|
|
42
|
+
*
|
|
43
|
+
* - create() opts → throw at boot
|
|
44
|
+
* - bad locale tag at any boundary → throw at call site
|
|
45
|
+
* - t(missingKey) → return key + observability event
|
|
46
|
+
* - t() with bad locale override → throw at call site (programming bug)
|
|
47
|
+
* - plural shape missing 'other' → throw at load time
|
|
48
|
+
* - interpolation missing var → render literal {var}
|
|
49
|
+
* - format* bad input → throw at call site
|
|
50
|
+
* - middleware Accept-Language parse error → fall back to defaultLocale
|
|
51
|
+
*
|
|
52
|
+
* Security stance: translation values come from operator-controlled
|
|
53
|
+
* files, not user input. {var} interpolation does NOT html-escape;
|
|
54
|
+
* `b.template` already escapes when rendered. Operators using t() in
|
|
55
|
+
* non-template contexts pass `interpolation.escape`.
|
|
56
|
+
*
|
|
57
|
+
* No audit-chain integration: i18n is not operator-action shaped (no
|
|
58
|
+
* state mutation). Routing observability events (missing key, locale
|
|
59
|
+
* fallback, formatter cache misses) is enough.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
var fs = require("node:fs");
|
|
63
|
+
var path = require("node:path");
|
|
64
|
+
var lazyRequire = require("./lazy-require");
|
|
65
|
+
var requestHelpers = require("./request-helpers");
|
|
66
|
+
var safeJson = require("./safe-json");
|
|
67
|
+
var validateOpts = require("./validate-opts");
|
|
68
|
+
var { I18nError } = require("./framework-error");
|
|
69
|
+
|
|
70
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
71
|
+
|
|
72
|
+
var _err = I18nError.factory;
|
|
73
|
+
|
|
74
|
+
// CLDR cardinal plural categories. PluralRules.select() returns one of
|
|
75
|
+
// these; translation files declare keys from this set. `other` is
|
|
76
|
+
// mandatory in any plural-shaped translation entry.
|
|
77
|
+
var PLURAL_CATEGORIES = Object.freeze(["zero", "one", "two", "few", "many", "other"]);
|
|
78
|
+
|
|
79
|
+
// BCP 47 language subtags whose default writing direction is RTL.
|
|
80
|
+
// Operators extend by passing rtlLanguages: [...DEFAULTS.RTL_LANGUAGES, "yourLang"]
|
|
81
|
+
// at create time. Sources: Unicode CLDR + W3C i18n recommendations.
|
|
82
|
+
var RTL_LANGUAGES = Object.freeze(new Set([
|
|
83
|
+
"ar", // Arabic
|
|
84
|
+
"fa", // Persian
|
|
85
|
+
"he", // Hebrew
|
|
86
|
+
"ur", // Urdu
|
|
87
|
+
"ps", // Pashto
|
|
88
|
+
"sd", // Sindhi
|
|
89
|
+
"yi", // Yiddish
|
|
90
|
+
"ckb", // Central Kurdish
|
|
91
|
+
"dv", // Divehi
|
|
92
|
+
]));
|
|
93
|
+
|
|
94
|
+
var DEFAULTS = Object.freeze({
|
|
95
|
+
fallbackLocale: null, // resolved at create() time (defaults to defaultLocale)
|
|
96
|
+
missingKey: "return-key",
|
|
97
|
+
interpolation: Object.freeze({ start: "{", end: "}", strict: false }),
|
|
98
|
+
RTL_LANGUAGES: RTL_LANGUAGES,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---- Call-site validation (throw on bad input) ----
|
|
102
|
+
|
|
103
|
+
function _isValidBcp47(tag) {
|
|
104
|
+
if (typeof tag !== "string" || tag.length === 0) return false;
|
|
105
|
+
try {
|
|
106
|
+
var canonical = Intl.getCanonicalLocales(tag);
|
|
107
|
+
return canonical.length === 1;
|
|
108
|
+
} catch (_e) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _validateLocale(name, value) {
|
|
114
|
+
if (!_isValidBcp47(value)) {
|
|
115
|
+
throw _err("BAD_LOCALE", name + " must be a valid BCP 47 language tag, got " +
|
|
116
|
+
JSON.stringify(value));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _validateLocaleArray(name, value) {
|
|
121
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
122
|
+
throw _err("BAD_OPT", name + " must be a non-empty array of BCP 47 tags");
|
|
123
|
+
}
|
|
124
|
+
for (var i = 0; i < value.length; i++) {
|
|
125
|
+
_validateLocale(name + "[" + i + "]", value[i]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _validateInterpolation(value) {
|
|
130
|
+
if (value === undefined) return DEFAULTS.interpolation;
|
|
131
|
+
if (typeof value !== "object" || value === null) {
|
|
132
|
+
throw _err("BAD_OPT", "i18n.create: interpolation must be an object");
|
|
133
|
+
}
|
|
134
|
+
var start = value.start !== undefined ? value.start : DEFAULTS.interpolation.start;
|
|
135
|
+
var end = value.end !== undefined ? value.end : DEFAULTS.interpolation.end;
|
|
136
|
+
if (typeof start !== "string" || start.length === 0) {
|
|
137
|
+
throw _err("BAD_OPT", "i18n.create: interpolation.start must be a non-empty string");
|
|
138
|
+
}
|
|
139
|
+
if (typeof end !== "string" || end.length === 0) {
|
|
140
|
+
throw _err("BAD_OPT", "i18n.create: interpolation.end must be a non-empty string");
|
|
141
|
+
}
|
|
142
|
+
if (value.escape !== undefined && typeof value.escape !== "function") {
|
|
143
|
+
throw _err("BAD_OPT", "i18n.create: interpolation.escape must be a function");
|
|
144
|
+
}
|
|
145
|
+
if (value.strict !== undefined && typeof value.strict !== "boolean") {
|
|
146
|
+
throw _err("BAD_OPT", "i18n.create: interpolation.strict must be a boolean");
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
start: start,
|
|
150
|
+
end: end,
|
|
151
|
+
escape: value.escape || null,
|
|
152
|
+
strict: value.strict === true,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _validateMissingKeyPolicy(value) {
|
|
157
|
+
if (value === undefined) return DEFAULTS.missingKey;
|
|
158
|
+
if (value === "return-key" || value === "throw") return value;
|
|
159
|
+
if (typeof value === "function") return value;
|
|
160
|
+
throw _err("BAD_OPT",
|
|
161
|
+
"i18n.create: missingKey must be 'return-key' / 'throw' / function, got " +
|
|
162
|
+
JSON.stringify(value));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _validateRtlList(value) {
|
|
166
|
+
if (value === undefined) return RTL_LANGUAGES;
|
|
167
|
+
if (!Array.isArray(value)) {
|
|
168
|
+
throw _err("BAD_OPT", "i18n.create: rtlLanguages must be an array of language subtags");
|
|
169
|
+
}
|
|
170
|
+
for (var i = 0; i < value.length; i++) {
|
|
171
|
+
if (typeof value[i] !== "string" || value[i].length === 0) {
|
|
172
|
+
throw _err("BAD_OPT", "i18n.create: rtlLanguages[" + i + "] must be a non-empty string");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return new Set(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- Translation tree validation ----
|
|
179
|
+
|
|
180
|
+
function _isPluralShape(node) {
|
|
181
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
182
|
+
// A plural-shaped node has ALL string values, AND at least one CLDR key.
|
|
183
|
+
var keys = Object.keys(node);
|
|
184
|
+
if (keys.length === 0) return false;
|
|
185
|
+
var hasCldrKey = false;
|
|
186
|
+
for (var i = 0; i < keys.length; i++) {
|
|
187
|
+
var k = keys[i];
|
|
188
|
+
if (PLURAL_CATEGORIES.indexOf(k) !== -1) hasCldrKey = true;
|
|
189
|
+
if (typeof node[k] !== "string") return false;
|
|
190
|
+
}
|
|
191
|
+
return hasCldrKey;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _validateTranslationTree(locale, node, dottedPath) {
|
|
195
|
+
if (typeof node === "string") return;
|
|
196
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
197
|
+
throw _err("BAD_TRANSLATIONS",
|
|
198
|
+
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
199
|
+
"': value must be a string or nested object");
|
|
200
|
+
}
|
|
201
|
+
if (_isPluralShape(node)) {
|
|
202
|
+
if (typeof node.other !== "string") {
|
|
203
|
+
throw _err("BAD_TRANSLATIONS",
|
|
204
|
+
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
205
|
+
"': plural-shaped entries must include an 'other' key (CLDR mandatory)");
|
|
206
|
+
}
|
|
207
|
+
// Reject unknown CLDR keys to catch typos like "ohter"
|
|
208
|
+
var keys = Object.keys(node);
|
|
209
|
+
for (var i = 0; i < keys.length; i++) {
|
|
210
|
+
if (PLURAL_CATEGORIES.indexOf(keys[i]) === -1) {
|
|
211
|
+
throw _err("BAD_TRANSLATIONS",
|
|
212
|
+
"i18n: locale '" + locale + "' key '" + dottedPath +
|
|
213
|
+
"' has unknown plural category '" + keys[i] +
|
|
214
|
+
"' (allowed: " + PLURAL_CATEGORIES.join(", ") + ")");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Recurse into nested namespace.
|
|
220
|
+
var nestedKeys = Object.keys(node);
|
|
221
|
+
for (var j = 0; j < nestedKeys.length; j++) {
|
|
222
|
+
var k = nestedKeys[j];
|
|
223
|
+
_validateTranslationTree(locale, node[k],
|
|
224
|
+
dottedPath ? (dottedPath + "." + k) : k);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _loadFromDir(dir, locales) {
|
|
229
|
+
var out = {};
|
|
230
|
+
for (var i = 0; i < locales.length; i++) {
|
|
231
|
+
var locale = locales[i];
|
|
232
|
+
var filePath = path.join(dir, locale + ".json");
|
|
233
|
+
if (!fs.existsSync(filePath)) {
|
|
234
|
+
throw _err("LOAD_FAILED",
|
|
235
|
+
"i18n: translations file not found for locale '" + locale + "': " + filePath);
|
|
236
|
+
}
|
|
237
|
+
var raw;
|
|
238
|
+
try { raw = fs.readFileSync(filePath, "utf8"); }
|
|
239
|
+
catch (e) {
|
|
240
|
+
throw _err("LOAD_FAILED",
|
|
241
|
+
"i18n: failed to read '" + filePath + "': " + ((e && e.message) || String(e)));
|
|
242
|
+
}
|
|
243
|
+
try { out[locale] = safeJson.parse(raw); }
|
|
244
|
+
catch (e) {
|
|
245
|
+
throw _err("LOAD_FAILED",
|
|
246
|
+
"i18n: failed to parse JSON for locale '" + locale + "' (" + filePath + "): " +
|
|
247
|
+
((e && e.message) || String(e)));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Validation: create opts ----
|
|
254
|
+
|
|
255
|
+
function _validateCreateOpts(opts) {
|
|
256
|
+
validateOpts.requireObject(opts, "i18n.create", I18nError);
|
|
257
|
+
_validateLocale("i18n.create: defaultLocale", opts.defaultLocale);
|
|
258
|
+
_validateLocaleArray("i18n.create: locales", opts.locales);
|
|
259
|
+
if (opts.locales.indexOf(opts.defaultLocale) === -1) {
|
|
260
|
+
throw _err("BAD_OPT",
|
|
261
|
+
"i18n.create: defaultLocale '" + opts.defaultLocale +
|
|
262
|
+
"' must be present in locales array");
|
|
263
|
+
}
|
|
264
|
+
if (opts.fallbackLocale !== undefined && opts.fallbackLocale !== null) {
|
|
265
|
+
_validateLocale("i18n.create: fallbackLocale", opts.fallbackLocale);
|
|
266
|
+
if (opts.locales.indexOf(opts.fallbackLocale) === -1) {
|
|
267
|
+
throw _err("BAD_OPT",
|
|
268
|
+
"i18n.create: fallbackLocale '" + opts.fallbackLocale +
|
|
269
|
+
"' must be present in locales array");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (opts.translations !== undefined && opts.dir !== undefined) {
|
|
273
|
+
throw _err("BAD_OPT",
|
|
274
|
+
"i18n.create: pass either translations OR dir, not both");
|
|
275
|
+
}
|
|
276
|
+
if (opts.translations !== undefined) {
|
|
277
|
+
if (typeof opts.translations !== "object" || opts.translations === null || Array.isArray(opts.translations)) {
|
|
278
|
+
throw _err("BAD_OPT", "i18n.create: translations must be an object keyed by locale");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
validateOpts.optionalNonEmptyString(opts.dir, "i18n.create: dir", I18nError);
|
|
282
|
+
validateOpts.observabilityShape(opts.observability, "i18n.create", I18nError);
|
|
283
|
+
validateOpts.optionalFunction(opts.clock, "i18n.create: clock", I18nError);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- Dotted-path resolution ----
|
|
287
|
+
|
|
288
|
+
function _resolveKey(tree, dottedKey) {
|
|
289
|
+
if (!tree || typeof tree !== "object") return undefined;
|
|
290
|
+
if (typeof dottedKey !== "string" || dottedKey.length === 0) return undefined;
|
|
291
|
+
// Fast path: no dots, direct lookup.
|
|
292
|
+
if (dottedKey.indexOf(".") === -1) return tree[dottedKey];
|
|
293
|
+
var parts = dottedKey.split(".");
|
|
294
|
+
var node = tree;
|
|
295
|
+
for (var i = 0; i < parts.length; i++) {
|
|
296
|
+
if (!node || typeof node !== "object") return undefined;
|
|
297
|
+
node = node[parts[i]];
|
|
298
|
+
if (node === undefined) return undefined;
|
|
299
|
+
}
|
|
300
|
+
return node;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- Interpolation ----
|
|
304
|
+
// Replace {var} placeholders with values from the vars object. Missing
|
|
305
|
+
// vars render as literal `{var}` unless interpolation.strict is true.
|
|
306
|
+
function _interpolate(template, vars, interpolation) {
|
|
307
|
+
if (!template || typeof template !== "string") return template;
|
|
308
|
+
// Use an empty object when caller passed nothing — strict mode still
|
|
309
|
+
// needs to walk the template so missing placeholders surface.
|
|
310
|
+
if (!vars || typeof vars !== "object") vars = {};
|
|
311
|
+
var start = interpolation.start;
|
|
312
|
+
var end = interpolation.end;
|
|
313
|
+
var escape = interpolation.escape;
|
|
314
|
+
var out = "";
|
|
315
|
+
var i = 0;
|
|
316
|
+
while (i < template.length) {
|
|
317
|
+
var openIdx = template.indexOf(start, i);
|
|
318
|
+
if (openIdx === -1) {
|
|
319
|
+
out += template.slice(i);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
out += template.slice(i, openIdx);
|
|
323
|
+
var closeIdx = template.indexOf(end, openIdx + start.length);
|
|
324
|
+
if (closeIdx === -1) {
|
|
325
|
+
// Unclosed delimiter — treat as literal text from openIdx to end.
|
|
326
|
+
out += template.slice(openIdx);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
var name = template.slice(openIdx + start.length, closeIdx).trim();
|
|
330
|
+
var hasVar = Object.prototype.hasOwnProperty.call(vars, name) && vars[name] !== undefined;
|
|
331
|
+
if (hasVar) {
|
|
332
|
+
var value = vars[name];
|
|
333
|
+
// null renders as empty string (operator passed null intentionally);
|
|
334
|
+
// undefined falls through to "missing" so devs see {var} surfacing
|
|
335
|
+
// and don't silently get blank UI from a typo'd variable.
|
|
336
|
+
var stringified = (value === null) ? "" : String(value);
|
|
337
|
+
out += escape ? escape(stringified) : stringified;
|
|
338
|
+
} else {
|
|
339
|
+
if (interpolation.strict) {
|
|
340
|
+
throw _err("MISSING_VAR",
|
|
341
|
+
"i18n: missing interpolation var '" + name + "' in '" + template + "'");
|
|
342
|
+
}
|
|
343
|
+
out += template.slice(openIdx, closeIdx + end.length);
|
|
344
|
+
}
|
|
345
|
+
i = closeIdx + end.length;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- Formatter caches ----
|
|
351
|
+
// `Intl.*` instances are expensive enough that allocating a fresh one
|
|
352
|
+
// per format() call shows up in perf traces under load. Cache by
|
|
353
|
+
// (locale, options-hash). Hash is JSON.stringify of the formatOpts —
|
|
354
|
+
// stable across object-literal calls and small enough we'd see drift
|
|
355
|
+
// only with operators handing in fresh literals every call (they
|
|
356
|
+
// usually pass the same shape).
|
|
357
|
+
function _makeFormatterCache(make, kind, emitObs) {
|
|
358
|
+
var cache = new Map();
|
|
359
|
+
return function getFormatter(locale, formatOpts) {
|
|
360
|
+
var optsKey = formatOpts ? JSON.stringify(formatOpts) : "";
|
|
361
|
+
var cacheKey = locale + "\x1f" + optsKey;
|
|
362
|
+
var f = cache.get(cacheKey);
|
|
363
|
+
if (!f) {
|
|
364
|
+
f = make(locale, formatOpts);
|
|
365
|
+
cache.set(cacheKey, f);
|
|
366
|
+
emitObs("i18n.format.created", { kind: kind, locale: locale });
|
|
367
|
+
}
|
|
368
|
+
return f;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---- Public create ----
|
|
373
|
+
|
|
374
|
+
function create(opts) {
|
|
375
|
+
opts = opts || {};
|
|
376
|
+
validateOpts(opts, [
|
|
377
|
+
"defaultLocale", "locales", "fallbackLocale",
|
|
378
|
+
"translations", "dir", "eagerLocales", "lazyLoad",
|
|
379
|
+
"interpolation", "missingKey", "onMissingKey", "rtlLanguages",
|
|
380
|
+
"observability", "clock",
|
|
381
|
+
], "b.i18n");
|
|
382
|
+
_validateCreateOpts(opts);
|
|
383
|
+
|
|
384
|
+
if (opts.lazyLoad === true && opts.translations) {
|
|
385
|
+
throw _err("BAD_OPT", "i18n.create: lazyLoad: true requires dir-based loading; " +
|
|
386
|
+
"translations: { ... } is inline-only and already complete at create time");
|
|
387
|
+
}
|
|
388
|
+
if (opts.eagerLocales !== undefined) {
|
|
389
|
+
if (!Array.isArray(opts.eagerLocales)) {
|
|
390
|
+
throw _err("BAD_OPT", "i18n.create: eagerLocales must be an array of BCP 47 tags");
|
|
391
|
+
}
|
|
392
|
+
for (var ei = 0; ei < opts.eagerLocales.length; ei++) {
|
|
393
|
+
_validateLocale("i18n.create: eagerLocales[" + ei + "]", opts.eagerLocales[ei]);
|
|
394
|
+
if (opts.locales.indexOf(opts.eagerLocales[ei]) === -1) {
|
|
395
|
+
throw _err("BAD_OPT", "i18n.create: eagerLocales[" + ei + "] '" +
|
|
396
|
+
opts.eagerLocales[ei] + "' must be in locales array");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (opts.onMissingKey !== undefined && typeof opts.onMissingKey !== "function") {
|
|
401
|
+
throw _err("BAD_OPT", "i18n.create: onMissingKey must be a function (key, locale)");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
var defaultLocale = opts.defaultLocale;
|
|
405
|
+
var locales = opts.locales.slice();
|
|
406
|
+
var fallbackLocale = (opts.fallbackLocale === null) ? null
|
|
407
|
+
: ((opts.fallbackLocale === undefined) ? defaultLocale : opts.fallbackLocale);
|
|
408
|
+
var interpolation = _validateInterpolation(opts.interpolation);
|
|
409
|
+
var missingKeyPolicy = _validateMissingKeyPolicy(opts.missingKey);
|
|
410
|
+
var rtlSet = _validateRtlList(opts.rtlLanguages);
|
|
411
|
+
var operatorObs = opts.observability || null;
|
|
412
|
+
|
|
413
|
+
// Translations: either inline object or loaded from dir at create.
|
|
414
|
+
// With lazyLoad, only eager locales hit disk now; the rest load on
|
|
415
|
+
// first lookup that resolves to them.
|
|
416
|
+
var translations;
|
|
417
|
+
var lazyLoadEnabled = false;
|
|
418
|
+
var lazyLoadDir = null;
|
|
419
|
+
var loadedSet = new Set();
|
|
420
|
+
if (opts.dir) {
|
|
421
|
+
if (opts.lazyLoad === true) {
|
|
422
|
+
lazyLoadEnabled = true;
|
|
423
|
+
lazyLoadDir = opts.dir;
|
|
424
|
+
var eager = Array.isArray(opts.eagerLocales) && opts.eagerLocales.length > 0
|
|
425
|
+
? opts.eagerLocales
|
|
426
|
+
: [defaultLocale];
|
|
427
|
+
translations = _loadFromDir(opts.dir, eager);
|
|
428
|
+
for (var ei2 = 0; ei2 < eager.length; ei2++) loadedSet.add(eager[ei2]);
|
|
429
|
+
} else {
|
|
430
|
+
translations = _loadFromDir(opts.dir, locales);
|
|
431
|
+
for (var ei3 = 0; ei3 < locales.length; ei3++) loadedSet.add(locales[ei3]);
|
|
432
|
+
}
|
|
433
|
+
} else if (opts.translations) {
|
|
434
|
+
translations = opts.translations;
|
|
435
|
+
for (var ei4 = 0; ei4 < locales.length; ei4++) {
|
|
436
|
+
if (translations[locales[ei4]]) loadedSet.add(locales[ei4]);
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
translations = {};
|
|
440
|
+
}
|
|
441
|
+
var onMissingKey = opts.onMissingKey || null;
|
|
442
|
+
// Validate translation trees up-front so plural-shape errors surface
|
|
443
|
+
// at boot, not at the first request that hits the broken key. Lazy
|
|
444
|
+
// locales validate on first load.
|
|
445
|
+
for (var li = 0; li < locales.length; li++) {
|
|
446
|
+
var loc = locales[li];
|
|
447
|
+
if (translations[loc]) {
|
|
448
|
+
_validateTranslationTree(loc, translations[loc], "");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _ensureLocaleLoaded(locale) {
|
|
453
|
+
if (loadedSet.has(locale)) return;
|
|
454
|
+
if (!lazyLoadEnabled || !lazyLoadDir) return; // not configured for lazy
|
|
455
|
+
if (!localesSet.has(locale)) return; // unknown locale; lookup falls through
|
|
456
|
+
var loaded = _loadFromDir(lazyLoadDir, [locale]);
|
|
457
|
+
translations[locale] = loaded[locale];
|
|
458
|
+
_validateTranslationTree(locale, translations[locale], "");
|
|
459
|
+
loadedSet.add(locale);
|
|
460
|
+
_emitObs("i18n.lazyLoad", { locale: locale });
|
|
461
|
+
}
|
|
462
|
+
var localesSet = new Set(locales);
|
|
463
|
+
var currentLocale = defaultLocale;
|
|
464
|
+
|
|
465
|
+
function _emitObs(name, labels) {
|
|
466
|
+
try {
|
|
467
|
+
if (operatorObs) operatorObs.event(name, 1, labels || {});
|
|
468
|
+
else observability().event(name, 1, labels || {});
|
|
469
|
+
} catch (_e) { /* drop-silent — observability sink must not crash i18n calls */ }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Cardinal plural-rules instances per locale. `Intl.PluralRules` is
|
|
473
|
+
// the modern replacement for gettext's plural-forms pragma.
|
|
474
|
+
var pluralRulesByLocale = {};
|
|
475
|
+
function _pluralRulesFor(locale) {
|
|
476
|
+
var r = pluralRulesByLocale[locale];
|
|
477
|
+
if (!r) {
|
|
478
|
+
r = new Intl.PluralRules(locale);
|
|
479
|
+
pluralRulesByLocale[locale] = r;
|
|
480
|
+
}
|
|
481
|
+
return r;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Per-kind formatter caches.
|
|
485
|
+
var numberFormatter = _makeFormatterCache(
|
|
486
|
+
function (locale, fopts) { return new Intl.NumberFormat(locale, fopts || undefined); },
|
|
487
|
+
"number", _emitObs);
|
|
488
|
+
var dateFormatter = _makeFormatterCache(
|
|
489
|
+
function (locale, fopts) { return new Intl.DateTimeFormat(locale, fopts || undefined); },
|
|
490
|
+
"date", _emitObs);
|
|
491
|
+
var relativeFormatter = _makeFormatterCache(
|
|
492
|
+
function (locale, fopts) { return new Intl.RelativeTimeFormat(locale, fopts || undefined); },
|
|
493
|
+
"relative", _emitObs);
|
|
494
|
+
var listFormatter = _makeFormatterCache(
|
|
495
|
+
function (locale, fopts) { return new Intl.ListFormat(locale, fopts || undefined); },
|
|
496
|
+
"list", _emitObs);
|
|
497
|
+
var displayNamesFormatter = _makeFormatterCache(
|
|
498
|
+
function (locale, fopts) { return new Intl.DisplayNames([locale], fopts || { type: "language" }); },
|
|
499
|
+
"displayNames", _emitObs);
|
|
500
|
+
|
|
501
|
+
function _resolveLocale(callerLocale) {
|
|
502
|
+
if (callerLocale === undefined || callerLocale === null) return currentLocale;
|
|
503
|
+
if (typeof callerLocale !== "string") {
|
|
504
|
+
throw _err("BAD_LOCALE", "i18n: locale override must be a string");
|
|
505
|
+
}
|
|
506
|
+
if (!_isValidBcp47(callerLocale)) {
|
|
507
|
+
throw _err("BAD_LOCALE", "i18n: locale '" + callerLocale + "' is not a valid BCP 47 tag");
|
|
508
|
+
}
|
|
509
|
+
return callerLocale;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _localeChain(locale) {
|
|
513
|
+
// Build the resolution chain. Start with the requested locale and
|
|
514
|
+
// strip subtag suffixes (`pt-BR` → `pt`); subtag stripping always
|
|
515
|
+
// applies because it's "same language, less specific" rather than a
|
|
516
|
+
// cross-locale jump. Cross-locale fallback (to fallbackLocale, then
|
|
517
|
+
// defaultLocale) only fires when fallbackLocale is non-null —
|
|
518
|
+
// operators who set fallbackLocale: null get strict "this locale or
|
|
519
|
+
// miss" semantics.
|
|
520
|
+
var chain = [];
|
|
521
|
+
var current = locale;
|
|
522
|
+
while (current && chain.indexOf(current) === -1) {
|
|
523
|
+
chain.push(current);
|
|
524
|
+
var dash = current.lastIndexOf("-");
|
|
525
|
+
if (dash === -1) break;
|
|
526
|
+
current = current.slice(0, dash);
|
|
527
|
+
}
|
|
528
|
+
if (fallbackLocale === null) return chain;
|
|
529
|
+
if (chain.indexOf(fallbackLocale) === -1) chain.push(fallbackLocale);
|
|
530
|
+
if (chain.indexOf(defaultLocale) === -1) chain.push(defaultLocale);
|
|
531
|
+
return chain;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _lookupRaw(key, locale) {
|
|
535
|
+
var chain = _localeChain(locale);
|
|
536
|
+
for (var i = 0; i < chain.length; i++) {
|
|
537
|
+
var loc = chain[i];
|
|
538
|
+
_ensureLocaleLoaded(loc);
|
|
539
|
+
if (!translations[loc]) continue;
|
|
540
|
+
var v = _resolveKey(translations[loc], key);
|
|
541
|
+
if (v !== undefined) {
|
|
542
|
+
return { value: v, foundIn: loc };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Ordinal-plural rules cache — separate from cardinal because Intl.PluralRules
|
|
549
|
+
// is type-fixed at construction.
|
|
550
|
+
var ordinalRulesByLocale = {};
|
|
551
|
+
function _ordinalRulesFor(locale) {
|
|
552
|
+
var r = ordinalRulesByLocale[locale];
|
|
553
|
+
if (!r) {
|
|
554
|
+
r = new Intl.PluralRules(locale, { type: "ordinal" });
|
|
555
|
+
ordinalRulesByLocale[locale] = r;
|
|
556
|
+
}
|
|
557
|
+
return r;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function _selectPlural(node, count, locale, ordinal) {
|
|
561
|
+
var rules = ordinal ? _ordinalRulesFor(locale) : _pluralRulesFor(locale);
|
|
562
|
+
var category = rules.select(count);
|
|
563
|
+
if (typeof node[category] === "string") return node[category];
|
|
564
|
+
// Fallback within the entry: "other" was validated mandatory at load.
|
|
565
|
+
return node.other;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function t(key, vars, callerOpts) {
|
|
569
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
570
|
+
throw _err("BAD_KEY", "i18n.t: key must be a non-empty string, got " + typeof key);
|
|
571
|
+
}
|
|
572
|
+
callerOpts = callerOpts || {};
|
|
573
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
574
|
+
var found = _lookupRaw(key, locale);
|
|
575
|
+
|
|
576
|
+
if (!found) {
|
|
577
|
+
_emitObs("i18n.missing", { locale: locale, key: key });
|
|
578
|
+
if (onMissingKey) {
|
|
579
|
+
try { onMissingKey(key, locale); }
|
|
580
|
+
catch (_e) { /* hook is best-effort; never break the request */ }
|
|
581
|
+
}
|
|
582
|
+
if (callerOpts.default !== undefined) return callerOpts.default;
|
|
583
|
+
if (typeof missingKeyPolicy === "function") {
|
|
584
|
+
return missingKeyPolicy(key, locale);
|
|
585
|
+
}
|
|
586
|
+
if (missingKeyPolicy === "throw") {
|
|
587
|
+
throw _err("MISSING_KEY",
|
|
588
|
+
"i18n.t: key '" + key + "' missing in locale chain for '" + locale + "'");
|
|
589
|
+
}
|
|
590
|
+
return key;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (found.foundIn !== locale) {
|
|
594
|
+
_emitObs("i18n.miss.fallback", { locale: locale, key: key });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
var raw;
|
|
598
|
+
if (typeof found.value === "string") {
|
|
599
|
+
raw = found.value;
|
|
600
|
+
} else if (_isPluralShape(found.value)) {
|
|
601
|
+
var count = (vars && typeof vars.count === "number") ? vars.count : 0;
|
|
602
|
+
raw = _selectPlural(found.value, count, found.foundIn, callerOpts.ordinal === true);
|
|
603
|
+
} else {
|
|
604
|
+
// Operator stored a nested tree at this key but called t() against
|
|
605
|
+
// the namespace. Return the key-path as a missing-key signal.
|
|
606
|
+
_emitObs("i18n.missing", { locale: locale, key: key });
|
|
607
|
+
return key;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ICU MessageFormat path — when the operator opts in via
|
|
611
|
+
// `messageFormat: true` OR the entry contains a `{name, plural,
|
|
612
|
+
// ...}` / `{name, select, ...}` / `{name, selectordinal, ...}`
|
|
613
|
+
// shape, evaluate via the parser. Otherwise fall back to the
|
|
614
|
+
// simple `{var}` interpolator (existing behaviour, unchanged).
|
|
615
|
+
var useMf = callerOpts.messageFormat === true ||
|
|
616
|
+
messageFormat.looksLikeMessageFormat(raw);
|
|
617
|
+
if (useMf) {
|
|
618
|
+
return messageFormat.format(raw, vars, found.foundIn);
|
|
619
|
+
}
|
|
620
|
+
return _interpolate(raw, vars, interpolation);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function tn(key, count, vars, callerOpts) {
|
|
624
|
+
if (typeof count !== "number" || !isFinite(count)) {
|
|
625
|
+
throw _err("BAD_INPUT", "i18n.tn: count must be a finite number, got " +
|
|
626
|
+
(typeof count) + " " + JSON.stringify(count));
|
|
627
|
+
}
|
|
628
|
+
var merged = vars ? Object.assign({}, vars, { count: count }) : { count: count };
|
|
629
|
+
return t(key, merged, callerOpts);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// to — ordinal-plural counterpart of tn. Selects from the entry using
|
|
633
|
+
// Intl.PluralRules({ type: "ordinal" }), so English keys
|
|
634
|
+
// { one: "{count}st", two: "{count}nd", few: "{count}rd", other: "{count}th" }
|
|
635
|
+
// resolve as "1st", "2nd", "3rd", "4th", "21st", etc.
|
|
636
|
+
function to(key, count, vars, callerOpts) {
|
|
637
|
+
if (typeof count !== "number" || !isFinite(count)) {
|
|
638
|
+
throw _err("BAD_INPUT", "i18n.to: count must be a finite number, got " +
|
|
639
|
+
(typeof count) + " " + JSON.stringify(count));
|
|
640
|
+
}
|
|
641
|
+
var merged = vars ? Object.assign({}, vars, { count: count }) : { count: count };
|
|
642
|
+
var withOrdinal = Object.assign({}, callerOpts || {}, { ordinal: true });
|
|
643
|
+
return t(key, merged, withOrdinal);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function has(key, callerOpts) {
|
|
647
|
+
callerOpts = callerOpts || {};
|
|
648
|
+
if (typeof key !== "string" || key.length === 0) return false;
|
|
649
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
650
|
+
var found = _lookupRaw(key, locale);
|
|
651
|
+
if (found === null) return false;
|
|
652
|
+
// A nested namespace object (not plural-shaped) is NOT a resolvable
|
|
653
|
+
// translation value — has() should report false so callers can gate
|
|
654
|
+
// "show this UI block only if translated" on leaf entries.
|
|
655
|
+
if (typeof found.value === "string") return true;
|
|
656
|
+
if (_isPluralShape(found.value)) return true;
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function formatNumber(value, formatOpts, callerOpts) {
|
|
661
|
+
if (typeof value !== "number" || !isFinite(value)) {
|
|
662
|
+
throw _err("BAD_INPUT", "i18n.formatNumber: value must be a finite number, got " +
|
|
663
|
+
(typeof value) + " " + JSON.stringify(value));
|
|
664
|
+
}
|
|
665
|
+
callerOpts = callerOpts || {};
|
|
666
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
667
|
+
return numberFormatter(locale, formatOpts).format(value);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function formatDate(value, formatOpts, callerOpts) {
|
|
671
|
+
var d = (value instanceof Date) ? value
|
|
672
|
+
: (typeof value === "number" || typeof value === "string") ? new Date(value)
|
|
673
|
+
: null;
|
|
674
|
+
if (!d || isNaN(d.getTime())) {
|
|
675
|
+
throw _err("BAD_INPUT", "i18n.formatDate: value must be a Date / number / parseable string");
|
|
676
|
+
}
|
|
677
|
+
callerOpts = callerOpts || {};
|
|
678
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
679
|
+
return dateFormatter(locale, formatOpts).format(d);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function formatRelative(value, unit, callerOpts) {
|
|
683
|
+
if (typeof value !== "number" || !isFinite(value)) {
|
|
684
|
+
throw _err("BAD_INPUT", "i18n.formatRelative: value must be a finite number");
|
|
685
|
+
}
|
|
686
|
+
if (typeof unit !== "string" || unit.length === 0) {
|
|
687
|
+
throw _err("BAD_INPUT", "i18n.formatRelative: unit must be a non-empty string");
|
|
688
|
+
}
|
|
689
|
+
callerOpts = callerOpts || {};
|
|
690
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
691
|
+
var fopts = callerOpts.numeric ? { numeric: callerOpts.numeric } : undefined;
|
|
692
|
+
return relativeFormatter(locale, fopts).format(value, unit);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function formatList(items, formatOpts, callerOpts) {
|
|
696
|
+
if (!Array.isArray(items)) {
|
|
697
|
+
throw _err("BAD_INPUT", "i18n.formatList: items must be an array of strings");
|
|
698
|
+
}
|
|
699
|
+
callerOpts = callerOpts || {};
|
|
700
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
701
|
+
return listFormatter(locale, formatOpts).format(items);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function displayName(code, type, callerOpts) {
|
|
705
|
+
if (typeof code !== "string" || code.length === 0) {
|
|
706
|
+
throw _err("BAD_INPUT", "i18n.displayName: code must be a non-empty string");
|
|
707
|
+
}
|
|
708
|
+
var allowed = ["language", "region", "currency", "script"];
|
|
709
|
+
if (allowed.indexOf(type) === -1) {
|
|
710
|
+
throw _err("BAD_INPUT", "i18n.displayName: type must be one of " + allowed.join(", "));
|
|
711
|
+
}
|
|
712
|
+
callerOpts = callerOpts || {};
|
|
713
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
714
|
+
var f = displayNamesFormatter(locale, { type: type });
|
|
715
|
+
return f.of(code);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function setLocale(newLocale) {
|
|
719
|
+
_validateLocale("i18n.setLocale", newLocale);
|
|
720
|
+
if (!localesSet.has(newLocale)) {
|
|
721
|
+
// Permit setting a non-configured locale (operators may want to
|
|
722
|
+
// experiment), but fall the chain through to the configured ones.
|
|
723
|
+
// Don't throw — i18n.locale is set/observed in many flows; making
|
|
724
|
+
// this throw would force operators into try/catch around UI setters.
|
|
725
|
+
_emitObs("i18n.miss.locale", { requested: newLocale, resolved: defaultLocale });
|
|
726
|
+
}
|
|
727
|
+
currentLocale = newLocale;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function dir(callerOpts) {
|
|
731
|
+
callerOpts = callerOpts || {};
|
|
732
|
+
var locale = _resolveLocale(callerOpts.locale);
|
|
733
|
+
var primary = locale.split("-")[0].toLowerCase();
|
|
734
|
+
return rtlSet.has(primary) ? "rtl" : "ltr";
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function getTranslationsFor(locale) {
|
|
738
|
+
if (typeof locale !== "string" || locale.length === 0) {
|
|
739
|
+
throw _err("BAD_INPUT", "i18n.translations: locale must be a string");
|
|
740
|
+
}
|
|
741
|
+
return translations[locale] || null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---- Locale negotiation (Accept-Language) ----
|
|
745
|
+
// Find the best match among configured locales using the parsed
|
|
746
|
+
// q-list. Longest-prefix wins per RFC 9110 §12.5.4.
|
|
747
|
+
function _negotiateLocale(parsedList) {
|
|
748
|
+
for (var i = 0; i < parsedList.length; i++) {
|
|
749
|
+
var requested = parsedList[i].value;
|
|
750
|
+
if (parsedList[i].q === 0) continue;
|
|
751
|
+
// Direct hit
|
|
752
|
+
if (localesSet.has(requested)) return requested;
|
|
753
|
+
// Prefix match: requested "pt-BR" → configured "pt"
|
|
754
|
+
var dash = requested.lastIndexOf("-");
|
|
755
|
+
while (dash !== -1) {
|
|
756
|
+
var prefix = requested.slice(0, dash);
|
|
757
|
+
if (localesSet.has(prefix)) return prefix;
|
|
758
|
+
dash = prefix.lastIndexOf("-");
|
|
759
|
+
}
|
|
760
|
+
// Reverse: requested "pt" → configured "pt-BR" (broaden — first match)
|
|
761
|
+
for (var j = 0; j < locales.length; j++) {
|
|
762
|
+
var loc = locales[j];
|
|
763
|
+
if (loc.toLowerCase().split("-")[0] === requested.toLowerCase().split("-")[0]) {
|
|
764
|
+
return loc;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return defaultLocale;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function middleware(mwOpts) {
|
|
772
|
+
mwOpts = mwOpts || {};
|
|
773
|
+
var headerName = (mwOpts.headerName || "accept-language").toLowerCase();
|
|
774
|
+
var queryParam = mwOpts.queryParam || "lang";
|
|
775
|
+
var cookieName = mwOpts.cookieName || null; // operator opt-in
|
|
776
|
+
var resolver = typeof mwOpts.resolver === "function" ? mwOpts.resolver : null;
|
|
777
|
+
|
|
778
|
+
return function i18nMiddleware(req, res, next) {
|
|
779
|
+
try {
|
|
780
|
+
var explicit = null;
|
|
781
|
+
if (resolver) {
|
|
782
|
+
try { explicit = resolver(req); }
|
|
783
|
+
catch (_e) { explicit = null; }
|
|
784
|
+
}
|
|
785
|
+
if (!explicit && req.query && typeof req.query[queryParam] === "string") {
|
|
786
|
+
explicit = req.query[queryParam];
|
|
787
|
+
}
|
|
788
|
+
if (!explicit && cookieName && req.cookies && typeof req.cookies[cookieName] === "string") {
|
|
789
|
+
explicit = req.cookies[cookieName];
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
var resolvedLocale;
|
|
793
|
+
if (explicit && _isValidBcp47(explicit) && localesSet.has(explicit)) {
|
|
794
|
+
resolvedLocale = explicit;
|
|
795
|
+
} else {
|
|
796
|
+
var headerValue = (req.headers && req.headers[headerName]) || null;
|
|
797
|
+
var parsed = requestHelpers.parseQualityList(headerValue);
|
|
798
|
+
resolvedLocale = _negotiateLocale(parsed);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Per-request bound t / dir so handlers can call without
|
|
802
|
+
// threading locale through every site.
|
|
803
|
+
function reqT(key, vars, callerOpts) {
|
|
804
|
+
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
805
|
+
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
806
|
+
return t(key, vars, c);
|
|
807
|
+
}
|
|
808
|
+
function reqTn(key, count, vars, callerOpts) {
|
|
809
|
+
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
810
|
+
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
811
|
+
return tn(key, count, vars, c);
|
|
812
|
+
}
|
|
813
|
+
function reqTo(key, count, vars, callerOpts) {
|
|
814
|
+
var c = callerOpts ? Object.assign({}, callerOpts) : {};
|
|
815
|
+
if (c.locale === undefined) c.locale = resolvedLocale;
|
|
816
|
+
return to(key, count, vars, c);
|
|
817
|
+
}
|
|
818
|
+
function reqDir() {
|
|
819
|
+
return dir({ locale: resolvedLocale });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
req.locale = resolvedLocale;
|
|
823
|
+
req.t = reqT;
|
|
824
|
+
req.tn = reqTn;
|
|
825
|
+
req.to = reqTo;
|
|
826
|
+
req.dir = reqDir;
|
|
827
|
+
if (res && typeof res === "object") {
|
|
828
|
+
if (!res.locals) res.locals = {};
|
|
829
|
+
res.locals.locale = resolvedLocale;
|
|
830
|
+
res.locals.t = reqT;
|
|
831
|
+
res.locals.tn = reqTn;
|
|
832
|
+
res.locals.to = reqTo;
|
|
833
|
+
res.locals.dir = reqDir();
|
|
834
|
+
}
|
|
835
|
+
} catch (_e) {
|
|
836
|
+
// Hot path — never crash the request because of i18n header parsing.
|
|
837
|
+
req.locale = currentLocale;
|
|
838
|
+
}
|
|
839
|
+
next();
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return {
|
|
844
|
+
t: t,
|
|
845
|
+
tn: tn,
|
|
846
|
+
to: to,
|
|
847
|
+
has: has,
|
|
848
|
+
formatNumber: formatNumber,
|
|
849
|
+
formatDate: formatDate,
|
|
850
|
+
formatRelative: formatRelative,
|
|
851
|
+
formatList: formatList,
|
|
852
|
+
displayName: displayName,
|
|
853
|
+
setLocale: setLocale,
|
|
854
|
+
dir: dir,
|
|
855
|
+
locales: function () { return locales.slice(); },
|
|
856
|
+
translations: getTranslationsFor,
|
|
857
|
+
middleware: middleware,
|
|
858
|
+
// Property getter so `i.locale` reflects setLocale changes.
|
|
859
|
+
get locale() { return currentLocale; },
|
|
860
|
+
// Test hooks
|
|
861
|
+
_localeChain: _localeChain,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ICU MessageFormat companion — top-level namespace so operators can
|
|
866
|
+
// pre-format strings outside the i18n instance (build pipeline, audit
|
|
867
|
+
// formatters, etc.). The instance returned by `create()` plumbs it
|
|
868
|
+
// through `t(key, vars, { messageFormat: true })`.
|
|
869
|
+
var messageFormat = require("./i18n-messageformat");
|
|
870
|
+
|
|
871
|
+
module.exports = {
|
|
872
|
+
create: create,
|
|
873
|
+
messageFormat: messageFormat,
|
|
874
|
+
I18nError: I18nError,
|
|
875
|
+
DEFAULTS: DEFAULTS,
|
|
876
|
+
RTL_LANGUAGES: RTL_LANGUAGES,
|
|
877
|
+
PLURAL_CATEGORIES: PLURAL_CATEGORIES,
|
|
878
|
+
};
|