locl-js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,513 @@
1
+ // src/formatters.ts
2
+ var defaultFormatters = {
3
+ /**
4
+ * Converts a string to uppercase.
5
+ * @param val - The value to format.
6
+ * @returns The uppercased string.
7
+ */
8
+ upper: (val) => String(val).toUpperCase(),
9
+ /**
10
+ * Converts a string to lowercase.
11
+ * @param val - The value to format.
12
+ * @returns The lowercased string.
13
+ */
14
+ lower: (val) => String(val).toLowerCase(),
15
+ /**
16
+ * Capitalizes the first letter of a string.
17
+ * @param val - The value to format.
18
+ * @returns The capitalized string.
19
+ */
20
+ capitalize: (val) => String(val).charAt(0).toUpperCase() + String(val).slice(1),
21
+ /**
22
+ * Trims whitespace from the beginning and end of a string.
23
+ * @param val - The value to format.
24
+ * @returns The trimmed string.
25
+ */
26
+ trim: (val) => String(val).trim(),
27
+ /**
28
+ * Truncates a string to a specified length.
29
+ * @param val - The value to format.
30
+ * @param args - An array of arguments.
31
+ * @param {number} [args[0]=10] - The maximum length of the string.
32
+ * @param {string} [args[1]="..."] - The suffix to append if the string is truncated.
33
+ * @returns The truncated string.
34
+ */
35
+ truncate: (val, args) => {
36
+ const length = parseInt(args[0]) || 10;
37
+ const suffix = args[1] || "...";
38
+ const str = String(val);
39
+ return str.length > length ? str.slice(0, length) + suffix : str;
40
+ },
41
+ /**
42
+ * Formats a number using `Intl.NumberFormat`.
43
+ * @param val - The value to format.
44
+ * @param args - An array of arguments.
45
+ * @param {string} [args[0]] - The locale to use.
46
+ * @param {string} [args[1]] - The style of formatting to use (e.g., "decimal", "percent").
47
+ * @returns The formatted number.
48
+ */
49
+ number: (val, args) => {
50
+ const locales = args[0] || void 0;
51
+ const options = {};
52
+ if (args[1]) options.style = args[1];
53
+ return new Intl.NumberFormat(locales, options).format(Number(val));
54
+ },
55
+ /**
56
+ * Formats a number as a currency string using `Intl.NumberFormat`.
57
+ * @param val - The value to format.
58
+ * @param args - An array of arguments.
59
+ * @param {string} [args[0]="USD"] - The currency code.
60
+ * @param {string} [args[1]] - The locale to use.
61
+ * @returns The formatted currency string.
62
+ */
63
+ currency: (val, args) => {
64
+ const currency = args[0] || "USD";
65
+ const locales = args[1] || void 0;
66
+ return new Intl.NumberFormat(locales, {
67
+ style: "currency",
68
+ currency
69
+ }).format(Number(val));
70
+ },
71
+ /**
72
+ * Formats a date using `Intl.DateTimeFormat`.
73
+ * @param val - The value to format.
74
+ * @param args - An array of arguments.
75
+ * @param {string} [args[0]] - The locale to use.
76
+ * @param {string} [args[1]] - The date style to use (e.g., "short", "long").
77
+ * @returns The formatted date string.
78
+ */
79
+ date: (val, args) => {
80
+ const locales = args[0] || void 0;
81
+ const options = {};
82
+ if (args[1]) options.dateStyle = args[1];
83
+ return new Intl.DateTimeFormat(locales, options).format(new Date(val));
84
+ },
85
+ /**
86
+ * Formats a date as a relative time string (e.g., "2 hours ago").
87
+ * @param val - The value to format.
88
+ * @param args - An array of arguments.
89
+ * @param {string} [args[0]] - The locale to use.
90
+ * @returns The relative time string.
91
+ */
92
+ relativeDate: (val, args) => {
93
+ const locales = args[0] || void 0;
94
+ const options = { numeric: "auto" };
95
+ const rtf = new Intl.RelativeTimeFormat(locales, options);
96
+ const diff = (new Date(val).getTime() - Date.now()) / 1e3;
97
+ if (Math.abs(diff) < 60) return rtf.format(Math.round(diff), "seconds");
98
+ if (Math.abs(diff) < 3600) return rtf.format(Math.round(diff / 60), "minutes");
99
+ if (Math.abs(diff) < 86400) return rtf.format(Math.round(diff / 3600), "hours");
100
+ return rtf.format(Math.round(diff / 86400), "days");
101
+ },
102
+ /**
103
+ * Converts a value to a JSON string.
104
+ * @param val - The value to format.
105
+ * @returns The JSON string.
106
+ */
107
+ json: (val) => JSON.stringify(val, null, 2),
108
+ /**
109
+ * Converts a boolean value to a "Yes" or "No" string.
110
+ * @param val - The value to format.
111
+ * @param args - An array of arguments.
112
+ * @param {string} [args[0]="Yes"] - The string to return for a truthy value.
113
+ * @param {string} [args[1]="No"] - The string to return for a falsy value.
114
+ * @returns "Yes" or "No".
115
+ */
116
+ yesNo: (val, args) => {
117
+ const [yesVal, noVal] = args || [];
118
+ return val ? yesVal || "Yes" : noVal || "No";
119
+ },
120
+ /**
121
+ * Converts a boolean value to a "true" or "false" string.
122
+ * @param val - The value to format.
123
+ * @param args - An array of arguments.
124
+ * @param {string} [args[0]="true"] - The string to return for a truthy value.
125
+ * @param {string} [args[1]="false"] - The string to return for a falsy value.
126
+ * @returns "true" or "false".
127
+ */
128
+ boolean: (val, args) => {
129
+ const [trueVal, falseVal] = args || [];
130
+ return val ? trueVal || "true" : falseVal || "false";
131
+ },
132
+ /**
133
+ * Pads the start of a string with another string.
134
+ * @param val - The value to format.
135
+ * @param args - An array of arguments.
136
+ * @param {number} [args[0]=0] - The target length of the string.
137
+ * @param {string} [args[1]=" "] - The string to pad with.
138
+ * @returns The padded string.
139
+ */
140
+ padStart: (val, args) => {
141
+ const length = parseInt(args[0]) || 0;
142
+ const fill = args[1] || " ";
143
+ return String(val).padStart(length, fill);
144
+ },
145
+ /**
146
+ * Pads the end of a string with another string.
147
+ * @param val - The value to format.
148
+ * @param args - An array of arguments.
149
+ * @param {number} [args[0]=0] - The target length of the string.
150
+ * @param {string} [args[1]=" "] - The string to pad with.
151
+ * @returns The padded string.
152
+ */
153
+ padEnd: (val, args) => {
154
+ const length = parseInt(args[0]) || 0;
155
+ const fill = args[1] || " ";
156
+ return String(val).padEnd(length, fill);
157
+ }
158
+ };
159
+
160
+ // src/LocL.ts
161
+ var LocL = class _LocL {
162
+ /**
163
+ * Creates a new LocL instance.
164
+ * @param config - The configuration object.
165
+ */
166
+ constructor(config) {
167
+ this.cache = /* @__PURE__ */ new Map();
168
+ this.proxyCache = /* @__PURE__ */ new WeakMap();
169
+ if (!config.resources) {
170
+ throw new Error("[LocL] `resources` is required");
171
+ }
172
+ if (!config.fallbackLanguage) {
173
+ throw new Error("[LocL] `fallbackLanguage` is required");
174
+ }
175
+ config = {
176
+ useDefaultFormatters: true,
177
+ devMode: false,
178
+ useCache: true,
179
+ ...config
180
+ };
181
+ config.resources = this.makeReadOnly(config.resources);
182
+ this.config = config;
183
+ this.language = this.isLanguage(config.language) ?? config.fallbackLanguage;
184
+ this.fallbackLanguage = config.fallbackLanguage;
185
+ this.scope = config.scope;
186
+ this.pluralRules = new Intl.PluralRules(this.language);
187
+ this.formatters = config.useDefaultFormatters ? { ...defaultFormatters, ...config.formatters ?? {} } : { ...config.formatters ?? {} };
188
+ }
189
+ /**
190
+ * Creates a new proxy translator instance with a different configuration.
191
+ * This is a lightweight way to create a translator with a different scope or language
192
+ * without creating a completely new instance.
193
+ * @param config - The configuration object.
194
+ * @param {N} [config.scope] - The scope to load translations from.
195
+ * @param {Language<T>} [config.language] - The language to use.
196
+ * @returns A new proxy `LocL` instance.
197
+ */
198
+ withConfig(config) {
199
+ const cacheKey = `proxy::${config.language ?? this.language}::${Array.isArray(config.scope) ? config.scope.join("|") : config.scope ?? "*"}`;
200
+ if (this.cache.has(cacheKey)) {
201
+ return this.cache.get(cacheKey);
202
+ }
203
+ return new Proxy(this, {
204
+ get(target, prop) {
205
+ if (prop === "language") {
206
+ return config.language ?? target.language;
207
+ }
208
+ if (prop === "scope") {
209
+ return config.scope;
210
+ }
211
+ return target[prop];
212
+ }
213
+ });
214
+ }
215
+ /**
216
+ * Creates a new `LocL` instance with a different language or scope.
217
+ * @param language - The language to use for the new instance. Defaults to the current language.
218
+ * @param scope - The scope to use for the new instance.
219
+ * @returns A new `LocL` instance.
220
+ */
221
+ clone(language = this.language, scope) {
222
+ return new _LocL({
223
+ ...this.config,
224
+ language,
225
+ scope
226
+ });
227
+ }
228
+ /**
229
+ * Changes the current language of the translator.
230
+ * @param lang - The new language to set.
231
+ */
232
+ changeLanguage(lang) {
233
+ if (Object.keys(this.config.resources).includes(lang)) {
234
+ this.language = lang;
235
+ this.pluralRules = new Intl.PluralRules(this.language);
236
+ }
237
+ }
238
+ t(key, values, format) {
239
+ if (!key) {
240
+ return this.get();
241
+ }
242
+ const pluralCheckResult = this.checkPlural(key, values);
243
+ if (pluralCheckResult !== void 0) {
244
+ return pluralCheckResult;
245
+ }
246
+ const result = this.lookupWithFallback(key);
247
+ const interpolated = this.interpolate(result, values) ?? key;
248
+ if (format && format.formatter) {
249
+ return this.applyFormat(interpolated, format.formatter, format.args);
250
+ }
251
+ return interpolated;
252
+ }
253
+ /**
254
+ * Translates a key without strict type checking.
255
+ * This is useful for compatibility with other i18n libraries or dynamic keys.
256
+ * @param key - The key to translate.
257
+ * @param values - An object with values to interpolate into the translation.
258
+ * @param format - An object with formatting options.
259
+ * @returns The translated and formatted string.
260
+ */
261
+ tt(key, values, format) {
262
+ return this.t(key, values, format);
263
+ }
264
+ /**
265
+ * Formats a value using a specific formatter.
266
+ * @param value - The value to format.
267
+ * @param formatter - The name of the formatter to use.
268
+ * @param args - An array of arguments to pass to the formatter.
269
+ * @returns The formatted string.
270
+ */
271
+ format(value, formatter, args) {
272
+ return this.applyFormat(value, formatter, args);
273
+ }
274
+ /**
275
+ * Translates a key with pluralization.
276
+ * It automatically selects the correct plural form based on the `count` value.
277
+ * @param key - The base key for the pluralization.
278
+ * @param values - An object with a `count` property and other values to interpolate.
279
+ * @param format - An object with formatting options.
280
+ * @returns The translated, pluralized, and formatted string.
281
+ */
282
+ plural(key, values, format) {
283
+ const category = this.pluralRules.select(values.count);
284
+ const baseKey = key;
285
+ const baseValue = this.lookupWithFallback(baseKey);
286
+ let interpolated;
287
+ if (baseValue && typeof baseValue === "object") {
288
+ if (category in baseValue) {
289
+ interpolated = this.interpolate(baseValue[category], values) ?? baseKey;
290
+ } else if ("other" in baseValue) {
291
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${baseKey}" in "${this.language}"`);
292
+ interpolated = this.interpolate(baseValue.other, values) ?? baseKey;
293
+ } else {
294
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${baseKey}" in "${this.language}"`);
295
+ interpolated = baseKey;
296
+ }
297
+ } else {
298
+ const pluralKey = `${baseKey}_${category}`;
299
+ const result = this.lookupWithFallback(pluralKey) ?? this.lookupWithFallback(`${baseKey}_other`) ?? this.lookupWithFallback(baseKey);
300
+ interpolated = this.interpolate(result, values) ?? baseKey;
301
+ }
302
+ if (format && format.formatter) {
303
+ return this.applyFormat(interpolated, format.formatter, format.args);
304
+ }
305
+ return interpolated;
306
+ }
307
+ get(key) {
308
+ const base = this.buildTranslationObject(this.language);
309
+ if (!key) {
310
+ return base;
311
+ }
312
+ return this.lookupWithFallback(key, base);
313
+ }
314
+ /**
315
+ * Gets a nested object from the translations.
316
+ * This is a type-safe way to get a nested object.
317
+ * @param key - The key of the object to get.
318
+ * @returns The nested translation object, or `undefined` if not found.
319
+ */
320
+ getObj(key) {
321
+ return this.get(key);
322
+ }
323
+ isLanguage(value) {
324
+ if (!value) {
325
+ return null;
326
+ }
327
+ const language = value.substring(0, 2);
328
+ return Object.keys(this.config.resources).includes(language) ? language : null;
329
+ }
330
+ getCacheKey(language) {
331
+ const scopeKey = Array.isArray(this.scope) ? this.scope.join("|") : this.scope ?? "*";
332
+ return `${language ?? this.language}::${scopeKey}`;
333
+ }
334
+ lookupWithFallback(key, base) {
335
+ const keys = key.split(".");
336
+ let result = this.findTranslation(keys, base ?? this.buildTranslationObject(this.language));
337
+ if (result === void 0 && this.config.devMode) {
338
+ console.warn(`[LocL] Missing key: "${key}" in "${this.language}"`);
339
+ }
340
+ if (result === void 0) {
341
+ const fbBase = this.buildTranslationObject(this.fallbackLanguage);
342
+ result = this.findTranslation(keys, fbBase);
343
+ }
344
+ if (result === void 0 && this.config.devMode) {
345
+ console.warn(`[LocL] Missing key: "${key}" in "${this.fallbackLanguage}"`);
346
+ }
347
+ return result;
348
+ }
349
+ buildTranslationObject(language) {
350
+ const langObject = this.config.resources[language];
351
+ if (!this.scope) {
352
+ return langObject;
353
+ }
354
+ const cacheKey = this.getCacheKey(language);
355
+ if (this.config.useCache && this.cache.has(cacheKey)) {
356
+ return this.cache.get(cacheKey);
357
+ }
358
+ if (typeof this.scope === "string") {
359
+ const result = this.scope.split(".").reduce((acc, k) => acc?.[k], langObject);
360
+ if (this.config.useCache) {
361
+ this.cache.set(cacheKey, result);
362
+ }
363
+ result === void 0 && this.config.devMode && console.warn(`[LocL] Missing namespace: "${this.scope}" in "${this.language}"`);
364
+ return result;
365
+ }
366
+ if (Array.isArray(this.scope)) {
367
+ const combined = {};
368
+ for (const mod of this.scope) {
369
+ const val = mod.split(".").reduce((acc, k) => acc?.[k], langObject);
370
+ if (val) {
371
+ mod.split(".").reduce((acc, k, i, arr) => {
372
+ if (i === arr.length - 1) {
373
+ acc[k] = val;
374
+ } else {
375
+ acc[k] ?? (acc[k] = {});
376
+ }
377
+ return acc[k];
378
+ }, combined);
379
+ }
380
+ }
381
+ const roCombined = this.makeReadOnly(combined);
382
+ if (this.config.useCache) {
383
+ this.cache.set(cacheKey, roCombined);
384
+ }
385
+ return roCombined;
386
+ }
387
+ if (this.config.useCache) {
388
+ this.cache.set(cacheKey, void 0);
389
+ }
390
+ this.config.devMode && console.warn(`[LocL] Missing namespace: "${this.scope}" in "${this.language}"`);
391
+ return void 0;
392
+ }
393
+ findTranslation(keys, translationObject) {
394
+ if (!translationObject || typeof translationObject !== "object") {
395
+ return void 0;
396
+ }
397
+ let current = translationObject;
398
+ const keysCopy = [...keys];
399
+ for (const key of keys) {
400
+ if (current === null || typeof current !== "object") {
401
+ return void 0;
402
+ }
403
+ const keyTest = keysCopy.join(".");
404
+ if (current.hasOwnProperty(keyTest)) {
405
+ return current[keyTest];
406
+ }
407
+ if (!(key in current)) {
408
+ return void 0;
409
+ }
410
+ current = current[key];
411
+ keysCopy.shift();
412
+ }
413
+ return current;
414
+ }
415
+ checkPlural(key, values) {
416
+ if (values?.count !== void 0 && !key.match(/_(one|few|many|other)$/)) {
417
+ const category = this.pluralRules.select(values.count);
418
+ const pluralKey = `${key}_${category}`;
419
+ const pluralResult = this.lookupWithFallback(pluralKey);
420
+ return this.interpolate(pluralResult, values);
421
+ }
422
+ }
423
+ interpolate(result, values) {
424
+ if (!values || typeof result !== "string") {
425
+ return result;
426
+ }
427
+ const tokens = [];
428
+ let depth = 0, start = -1;
429
+ for (let i = 0; i < result.length; i++) {
430
+ if (result[i] === "{") {
431
+ if (depth === 0) {
432
+ start = i;
433
+ }
434
+ depth++;
435
+ } else if (result[i] === "}") {
436
+ depth--;
437
+ if (depth === 0 && start >= 0) {
438
+ const raw = result.slice(start, i + 1);
439
+ tokens.push({ raw, inner: raw.slice(1, -1).trim() });
440
+ start = -1;
441
+ }
442
+ }
443
+ }
444
+ let output = result;
445
+ for (const token of tokens) {
446
+ let replacement = "";
447
+ if (token.inner.includes("select")) {
448
+ const [field, type, ...rest] = token.inner.split(",").map((x) => x.trim());
449
+ if (type === "select") {
450
+ const value = String(values[field] ?? "other");
451
+ const caseRegex = /(\w+)\s*\{([^}]*)\}/g;
452
+ const caseMap = {};
453
+ let match;
454
+ while ((match = caseRegex.exec(rest.join(" "))) !== null) {
455
+ const [, key, text] = match;
456
+ caseMap[key] = text.trim();
457
+ }
458
+ replacement = caseMap[value] ?? caseMap.other ?? "";
459
+ }
460
+ } else {
461
+ const [fieldName, formatterAndArgs] = token.inner.split("|").map((x) => x.trim());
462
+ const [formatterName, argsStr] = formatterAndArgs ? formatterAndArgs.split(":") : [];
463
+ const args = argsStr ? argsStr.split(",") : [];
464
+ const val = values[fieldName];
465
+ if (formatterName && this.formatters?.[formatterName]) {
466
+ replacement = this.formatters[formatterName](val, args);
467
+ } else if (val instanceof Date) {
468
+ replacement = new Intl.DateTimeFormat(this.language).format(val);
469
+ } else if (typeof val === "number") {
470
+ replacement = new Intl.NumberFormat(this.language).format(val);
471
+ } else {
472
+ replacement = val !== void 0 ? String(val) : token.raw;
473
+ }
474
+ }
475
+ output = output.replace(token.raw, replacement);
476
+ }
477
+ return output;
478
+ }
479
+ applyFormat(value, formatter, args) {
480
+ const formatterFn = this.formatters?.[formatter];
481
+ if (!formatterFn) {
482
+ this.config.devMode && console.warn(`[LocL] Formatter "${formatter.toString()}" not found.`);
483
+ return value;
484
+ }
485
+ return formatterFn(value, args);
486
+ }
487
+ makeReadOnly(obj) {
488
+ const self = this;
489
+ if (this.proxyCache.has(obj)) {
490
+ return this.proxyCache.get(obj);
491
+ }
492
+ const proxy = new Proxy(obj, {
493
+ get(target, prop, receiver) {
494
+ const val = Reflect.get(target, prop, receiver);
495
+ return val && typeof val === "object" ? self.makeReadOnly(val) : val;
496
+ },
497
+ set: () => false,
498
+ deleteProperty: () => false,
499
+ defineProperty: () => false,
500
+ setPrototypeOf: () => false
501
+ });
502
+ this.proxyCache.set(obj, proxy);
503
+ return proxy;
504
+ }
505
+ };
506
+
507
+ // src/index.ts
508
+ function initLocL(config) {
509
+ return new LocL(config);
510
+ }
511
+ export {
512
+ initLocL
513
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "locl-js",
3
+ "version": "1.0.0",
4
+ "description": "Modern TypeScript internationalization (i18n) library with type-safety, performance, and backend usability",
5
+ "license": "MIT",
6
+ "author": "Sztrida Sámuel <sztsam@gmail.com>",
7
+ "main": "dist/index.cjs",
8
+ "module": "dist/index.mjs",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/sztsam/LocL.js.git"
19
+ },
20
+ "homepage": "https://github.com/sztsam/LocL.js",
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "dev": "tsup --watch",
24
+ "test": "jest"
25
+ },
26
+ "devDependencies": {
27
+ "@types/jest": "^30.0.0",
28
+ "@types/node": "^24.5.2",
29
+ "jest": "^30.1.3",
30
+ "ts-jest": "^29.4.4",
31
+ "ts-node": "^10.9.2",
32
+ "tsup": "^8.5.0",
33
+ "typescript": "^5.9.2"
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "keywords": [
39
+ "i18n",
40
+ "internationalization",
41
+ "localization",
42
+ "translation",
43
+ "typescript",
44
+ "type-safe",
45
+ "language",
46
+ "plurals",
47
+ "formatting",
48
+ "modular"
49
+ ]
50
+ }