locl-js 1.0.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -18,11 +18,13 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
 
20
20
  // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ LocL: () => LocL,
24
+ defineResources: () => defineResources,
23
25
  initLocL: () => initLocL
24
26
  });
25
- module.exports = __toCommonJS(index_exports);
27
+ module.exports = __toCommonJS(src_exports);
26
28
 
27
29
  // src/formatters.ts
28
30
  var defaultFormatters = {
@@ -192,25 +194,26 @@ var LocL = class _LocL {
192
194
  constructor(config) {
193
195
  this.cache = /* @__PURE__ */ new Map();
194
196
  this.proxyCache = /* @__PURE__ */ new WeakMap();
197
+ this.pluralRulesCache = /* @__PURE__ */ new Map();
198
+ this.subscribers = /* @__PURE__ */ new Set();
199
+ this.rootInstance = this;
195
200
  if (!config.resources) {
196
201
  throw new Error("[LocL] `resources` is required");
197
202
  }
198
203
  if (!config.fallbackLanguage) {
199
204
  throw new Error("[LocL] `fallbackLanguage` is required");
200
205
  }
201
- config = {
206
+ this.config = {
202
207
  useDefaultFormatters: true,
203
208
  devMode: false,
204
209
  useCache: true,
205
210
  ...config
206
211
  };
207
- config.resources = this.makeReadOnly(config.resources);
208
- this.config = config;
212
+ this.resources = { ...config.resources };
209
213
  this.language = this.isLanguage(config.language) ?? config.fallbackLanguage;
210
214
  this.fallbackLanguage = config.fallbackLanguage;
211
215
  this.scope = config.scope;
212
- this.pluralRules = new Intl.PluralRules(this.language);
213
- this.formatters = config.useDefaultFormatters ? { ...defaultFormatters, ...config.formatters ?? {} } : { ...config.formatters ?? {} };
216
+ this.formatters = this.config.useDefaultFormatters ? { ...defaultFormatters, ...config.formatters ?? {} } : { ...config.formatters ?? {} };
214
217
  }
215
218
  /**
216
219
  * Creates a new proxy translator instance with a different configuration.
@@ -222,22 +225,39 @@ var LocL = class _LocL {
222
225
  * @returns A new proxy `LocL` instance.
223
226
  */
224
227
  withConfig(config) {
228
+ const root = this.rootInstance;
225
229
  const cacheKey = `proxy::${config.language ?? this.language}::${Array.isArray(config.scope) ? config.scope.join("|") : config.scope ?? "*"}`;
226
- if (this.cache.has(cacheKey)) {
227
- return this.cache.get(cacheKey);
230
+ if (root.cache.has(cacheKey)) {
231
+ return root.cache.get(cacheKey);
228
232
  }
229
- const result = new Proxy(this, {
230
- get(target, prop) {
233
+ const result = new Proxy(root, {
234
+ get(target, prop, receiver) {
231
235
  if (prop === "language") {
232
236
  return config.language ?? target.language;
233
237
  }
234
238
  if (prop === "scope") {
235
- return config.scope;
239
+ return config.scope !== void 0 ? config.scope : target.scope;
240
+ }
241
+ if (prop === "rootInstance") {
242
+ return target;
243
+ }
244
+ if (prop === "subscribe" && config.language !== void 0) {
245
+ return (listener) => {
246
+ try {
247
+ listener(config.language, config.language);
248
+ } catch (err) {
249
+ console.error("[LocL] Error in subscriber:", err);
250
+ }
251
+ return () => {
252
+ };
253
+ };
236
254
  }
237
- return target[prop];
255
+ return Reflect.get(target, prop, receiver);
238
256
  }
239
257
  });
240
- this.cache.set(cacheKey, result);
258
+ if (root.config.useCache) {
259
+ root.cache.set(cacheKey, result);
260
+ }
241
261
  return result;
242
262
  }
243
263
  /**
@@ -249,18 +269,110 @@ var LocL = class _LocL {
249
269
  clone(language = this.language, scope) {
250
270
  return new _LocL({
251
271
  ...this.config,
272
+ resources: this.resources,
252
273
  language,
253
274
  scope
254
275
  });
255
276
  }
277
+ /**
278
+ * Subscribes a listener to language changes.
279
+ * Conforms to the standard reactive store contract (e.g. Svelte stores).
280
+ * Calls the listener immediately with the current language and returns an unsubscribe function.
281
+ * @param listener - The subscriber callback function.
282
+ * @returns An unsubscribe function.
283
+ */
284
+ subscribe(listener) {
285
+ this.subscribers.add(listener);
286
+ try {
287
+ listener(this.language, this.language);
288
+ } catch (err) {
289
+ console.error("[LocL] Error in initial subscriber call:", err);
290
+ }
291
+ return () => {
292
+ this.subscribers.delete(listener);
293
+ };
294
+ }
295
+ /**
296
+ * Gets the current active language.
297
+ */
298
+ getLanguage() {
299
+ return this.language;
300
+ }
301
+ /**
302
+ * Adds or overrides a single translation key at runtime.
303
+ * @param lang - Target language code.
304
+ * @param key - Dotted path key.
305
+ * @param value - The translation value.
306
+ */
307
+ addResource(lang, key, value) {
308
+ if (this.isUnsafeObjectKey(lang)) {
309
+ return;
310
+ }
311
+ const keys = key.split(".");
312
+ if (keys.some((k) => this.isUnsafeObjectKey(k))) {
313
+ return;
314
+ }
315
+ if (!this.resources[lang]) {
316
+ this.resources[lang] = /* @__PURE__ */ Object.create(null);
317
+ }
318
+ let current = this.resources[lang];
319
+ for (let i = 0; i < keys.length - 1; i++) {
320
+ const k = keys[i];
321
+ if (!current[k] || typeof current[k] !== "object") {
322
+ current[k] = /* @__PURE__ */ Object.create(null);
323
+ }
324
+ current = current[k];
325
+ }
326
+ const lastKey = keys[keys.length - 1];
327
+ current[lastKey] = value;
328
+ this.invalidateCacheForLang(lang);
329
+ }
330
+ /**
331
+ * Deeply merges a resource bundle into the specified language at runtime.
332
+ * @param lang - Target language code.
333
+ * @param bundle - Object of translations to merge.
334
+ */
335
+ addResources(lang, bundle) {
336
+ if (this.isUnsafeObjectKey(lang)) {
337
+ return;
338
+ }
339
+ if (!this.resources[lang]) {
340
+ this.resources[lang] = /* @__PURE__ */ Object.create(null);
341
+ }
342
+ const deepMerge = (target, source) => {
343
+ for (const k of Object.keys(source)) {
344
+ if (this.isUnsafeObjectKey(k)) {
345
+ continue;
346
+ }
347
+ if (source[k] && typeof source[k] === "object" && !Array.isArray(source[k])) {
348
+ if (!target[k] || typeof target[k] !== "object") {
349
+ target[k] = /* @__PURE__ */ Object.create(null);
350
+ }
351
+ deepMerge(target[k], source[k]);
352
+ } else {
353
+ target[k] = source[k];
354
+ }
355
+ }
356
+ };
357
+ deepMerge(this.resources[lang], bundle);
358
+ this.invalidateCacheForLang(lang);
359
+ }
256
360
  /**
257
361
  * Changes the current language of the translator.
258
362
  * @param lang - The new language to set.
259
363
  */
260
364
  changeLanguage(lang) {
261
- if (Object.keys(this.config.resources).includes(lang)) {
262
- this.language = lang;
263
- this.pluralRules = new Intl.PluralRules(this.language);
365
+ const validated = this.isLanguage(lang);
366
+ if (validated && validated !== this.language) {
367
+ const prev = this.language;
368
+ this.language = validated;
369
+ for (const sub of this.subscribers) {
370
+ try {
371
+ sub(this.language, prev);
372
+ } catch (err) {
373
+ console.error("[LocL] Error in subscriber:", err);
374
+ }
375
+ }
264
376
  }
265
377
  }
266
378
  t(key, values, format) {
@@ -269,6 +381,9 @@ var LocL = class _LocL {
269
381
  }
270
382
  const pluralCheckResult = this.checkPlural(key, values);
271
383
  if (pluralCheckResult !== void 0) {
384
+ if (format && format.formatter) {
385
+ return this.applyFormat(pluralCheckResult, format.formatter, format.args);
386
+ }
272
387
  return pluralCheckResult;
273
388
  }
274
389
  const result = this.lookupWithFallback(key);
@@ -299,45 +414,54 @@ var LocL = class _LocL {
299
414
  format(value, formatter, args) {
300
415
  return this.applyFormat(value, formatter, args);
301
416
  }
417
+ plural(key, values, format) {
418
+ return this.t(key, values, format);
419
+ }
302
420
  /**
303
- * Translates a key with pluralization.
304
- * It automatically selects the correct plural form based on the `count` value.
305
- * @param key - The base key for the pluralization.
306
- * @param values - An object with a `count` property and other values to interpolate.
307
- * @param format - An object with formatting options.
308
- * @returns The translated, pluralized, and formatted string.
421
+ * Translates a key and interpolates rich elements using tag functions.
422
+ * Matches `<tag>content</tag>` in translation templates.
423
+ * @param key - The key to translate.
424
+ * @param tags - An object mapping tag names to functions that transform their inner content.
425
+ * @param values - Values to interpolate into variable placeholders ({name}, {{name}}).
426
+ * @returns An array of chunks (strings and custom rendered tag values).
427
+ * @example
428
+ * ```ts
429
+ * translator.rich("terms", {
430
+ * link: (content) => `<a href="/terms">${content}</a>`
431
+ * }, { name: "Alice" });
432
+ * ```
309
433
  */
310
- plural(key, values, format) {
311
- const category = this.pluralRules.select(values.count);
312
- const baseKey = key;
313
- const baseValue = this.lookupWithFallback(baseKey);
314
- let interpolated;
315
- if (baseValue && typeof baseValue === "object") {
316
- if (category in baseValue) {
317
- interpolated = this.interpolate(baseValue[category], values) ?? baseKey;
318
- } else if ("other" in baseValue) {
319
- this.config.devMode && console.warn(`[LocL] Missing plural: "${baseKey}" in "${this.language}"`);
320
- interpolated = this.interpolate(baseValue.other, values) ?? baseKey;
321
- } else {
322
- this.config.devMode && console.warn(`[LocL] Missing plural: "${baseKey}" in "${this.language}"`);
323
- interpolated = baseKey;
434
+ rich(key, tags, values) {
435
+ const rawStr = this.t(key, values);
436
+ if (typeof rawStr !== "string") {
437
+ return [rawStr];
438
+ }
439
+ const tagRegex = /<([a-zA-Z0-9_-]+)>([\s\S]*?)<\/\1>/g;
440
+ const result = [];
441
+ let lastIndex = 0;
442
+ let match;
443
+ while ((match = tagRegex.exec(rawStr)) !== null) {
444
+ if (match.index > lastIndex) {
445
+ result.push(rawStr.slice(lastIndex, match.index));
324
446
  }
325
- } else {
326
- const pluralKey = `${baseKey}_${category}`;
327
- const result = this.lookupWithFallback(pluralKey) ?? this.lookupWithFallback(`${baseKey}_other`) ?? this.lookupWithFallback(baseKey);
328
- interpolated = this.interpolate(result, values) ?? baseKey;
447
+ const tagName = match[1];
448
+ const content = match[2];
449
+ const renderer = tags[tagName];
450
+ typeof renderer === "function" ? result.push(renderer(content)) : result.push(match[0]);
451
+ lastIndex = match.index + match[0].length;
329
452
  }
330
- if (format && format.formatter) {
331
- return this.applyFormat(interpolated, format.formatter, format.args);
453
+ if (lastIndex < rawStr.length) {
454
+ result.push(rawStr.slice(lastIndex));
332
455
  }
333
- return interpolated;
456
+ return result;
334
457
  }
335
458
  get(key) {
336
459
  const base = this.buildTranslationObject(this.language);
337
460
  if (!key) {
338
- return base;
461
+ return base ? this.makeReadOnly(base) : void 0;
339
462
  }
340
- return this.lookupWithFallback(key, base);
463
+ const val = this.lookupWithFallback(key, base);
464
+ return val && typeof val === "object" ? this.makeReadOnly(val) : val;
341
465
  }
342
466
  /**
343
467
  * Gets a nested object from the translations.
@@ -352,12 +476,27 @@ var LocL = class _LocL {
352
476
  if (!value) {
353
477
  return null;
354
478
  }
355
- const language = value.substring(0, 2);
356
- return Object.keys(this.config.resources).includes(language) ? language : null;
479
+ const available = Object.keys(this.resources);
480
+ if (available.includes(value)) {
481
+ return value;
482
+ }
483
+ const prefix = value.substring(0, 2);
484
+ if (available.includes(prefix)) {
485
+ return prefix;
486
+ }
487
+ return null;
357
488
  }
358
489
  getCacheKey(language) {
359
- const scopeKey = Array.isArray(this.scope) ? this.scope.join("|") : this.scope ?? "*";
360
- return `${language ?? this.language}::${scopeKey}`;
490
+ const scopeKey = Array.isArray(this.scope) ? this.scope.join("|") : this.scope;
491
+ return `${language}::${scopeKey}`;
492
+ }
493
+ invalidateCacheForLang(lang) {
494
+ const prefix = `${lang}::`;
495
+ for (const key of this.cache.keys()) {
496
+ if (key.startsWith(prefix)) {
497
+ this.cache.delete(key);
498
+ }
499
+ }
361
500
  }
362
501
  lookupWithFallback(key, base) {
363
502
  const keys = key.split(".");
@@ -375,7 +514,7 @@ var LocL = class _LocL {
375
514
  return result;
376
515
  }
377
516
  buildTranslationObject(language) {
378
- const langObject = this.config.resources[language];
517
+ const langObject = this.resources[language];
379
518
  if (!this.scope) {
380
519
  return langObject;
381
520
  }
@@ -406,11 +545,10 @@ var LocL = class _LocL {
406
545
  }, combined);
407
546
  }
408
547
  }
409
- const roCombined = this.makeReadOnly(combined);
410
548
  if (this.config.useCache) {
411
- this.cache.set(cacheKey, roCombined);
549
+ this.cache.set(cacheKey, combined);
412
550
  }
413
- return roCombined;
551
+ return combined;
414
552
  }
415
553
  if (this.config.useCache) {
416
554
  this.cache.set(cacheKey, void 0);
@@ -429,7 +567,7 @@ var LocL = class _LocL {
429
567
  return void 0;
430
568
  }
431
569
  const keyTest = keysCopy.join(".");
432
- if (current.hasOwnProperty(keyTest)) {
570
+ if (Object.prototype.hasOwnProperty.call(current, keyTest)) {
433
571
  return current[keyTest];
434
572
  }
435
573
  if (!(key in current)) {
@@ -440,13 +578,54 @@ var LocL = class _LocL {
440
578
  }
441
579
  return current;
442
580
  }
581
+ getPluralRules(lang) {
582
+ let rules = this.pluralRulesCache.get(lang);
583
+ if (!rules) {
584
+ rules = new Intl.PluralRules(lang);
585
+ this.pluralRulesCache.set(lang, rules);
586
+ }
587
+ return rules;
588
+ }
443
589
  checkPlural(key, values) {
444
- if (values?.count !== void 0 && !key.match(/_(one|few|many|other)$/)) {
445
- const category = this.pluralRules.select(values.count);
446
- const pluralKey = `${key}_${category}`;
447
- const pluralResult = this.lookupWithFallback(pluralKey);
448
- return this.interpolate(pluralResult, values);
590
+ if (values?.count !== void 0 && !key.match(/_(zero|one|two|few|many|other)$/)) {
591
+ const category = this.getPluralRules(this.language).select(values.count);
592
+ const baseValue = this.lookupWithFallback(key, void 0);
593
+ if (baseValue && typeof baseValue === "object") {
594
+ if (values.count === 0 && "zero" in baseValue) {
595
+ return this.interpolate(baseValue.zero, values);
596
+ }
597
+ if (category in baseValue) {
598
+ return this.interpolate(baseValue[category], values);
599
+ } else if ("other" in baseValue) {
600
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
601
+ return this.interpolate(baseValue.other, values);
602
+ } else {
603
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
604
+ return key;
605
+ }
606
+ }
607
+ if (values.count === 0) {
608
+ const zeroResult = this.lookupWithFallback(`${key}_zero`, void 0);
609
+ if (zeroResult !== void 0) {
610
+ return this.interpolate(zeroResult, values);
611
+ }
612
+ }
613
+ const categoryResult = this.lookupWithFallback(`${key}_${category}`, void 0);
614
+ if (categoryResult !== void 0) {
615
+ return this.interpolate(categoryResult, values);
616
+ }
617
+ const otherResult = this.lookupWithFallback(`${key}_other`, void 0);
618
+ if (otherResult !== void 0) {
619
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
620
+ return this.interpolate(otherResult, values);
621
+ }
622
+ const anySuffixExists = ["zero", "one", "two", "few", "many"].some((cat) => this.lookupWithFallback(`${key}_${cat}`, void 0) !== void 0);
623
+ if (anySuffixExists) {
624
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
625
+ return key;
626
+ }
449
627
  }
628
+ return void 0;
450
629
  }
451
630
  interpolate(result, values) {
452
631
  if (!values || typeof result !== "string") {
@@ -464,34 +643,66 @@ var LocL = class _LocL {
464
643
  depth--;
465
644
  if (depth === 0 && start >= 0) {
466
645
  const raw = result.slice(start, i + 1);
467
- tokens.push({ raw, inner: raw.slice(1, -1).trim() });
646
+ let inner = raw.slice(1, -1);
647
+ if (inner.startsWith("{") && inner.endsWith("}")) {
648
+ inner = inner.slice(1, -1);
649
+ }
650
+ tokens.push({ raw, inner });
468
651
  start = -1;
469
652
  }
470
653
  }
471
654
  }
472
655
  let output = result;
656
+ const processed = /* @__PURE__ */ new Set();
473
657
  for (const token of tokens) {
658
+ if (processed.has(token.raw)) {
659
+ continue;
660
+ }
661
+ processed.add(token.raw);
474
662
  let replacement = "";
475
- if (token.inner.includes("select")) {
476
- const [field, type, ...rest] = token.inner.split(",").map((x) => x.trim());
477
- if (type === "select") {
478
- const value = String(values[field] ?? "other");
479
- const caseRegex = /(\w+)\s*\{([^}]*)\}/g;
480
- const caseMap = {};
481
- let match;
482
- while ((match = caseRegex.exec(rest.join(" "))) !== null) {
483
- const [, key, text] = match;
484
- caseMap[key] = text.trim();
663
+ const selectMatch = token.inner.match(/^\s*([^,]+),\s*select\s*,\s*([\s\S]+)$/);
664
+ if (selectMatch) {
665
+ const field = selectMatch[1].trim();
666
+ const casesBody = selectMatch[2];
667
+ const value = String(values[field] ?? "other");
668
+ const caseMap = {};
669
+ let i = 0;
670
+ while (i < casesBody.length) {
671
+ while (i < casesBody.length && /\s/.test(casesBody[i])) i++;
672
+ if (i >= casesBody.length) break;
673
+ let keyStart = i;
674
+ while (i < casesBody.length && casesBody[i] !== "{" && !/\s/.test(casesBody[i])) i++;
675
+ const caseKey = casesBody.slice(keyStart, i).trim();
676
+ while (i < casesBody.length && casesBody[i] !== "{") i++;
677
+ if (casesBody[i] === "{") {
678
+ let bodyDepth = 1;
679
+ let bodyStart = i + 1;
680
+ i++;
681
+ while (i < casesBody.length && bodyDepth > 0) {
682
+ if (casesBody[i] === "{") bodyDepth++;
683
+ else if (casesBody[i] === "}") bodyDepth--;
684
+ i++;
685
+ }
686
+ const caseText = casesBody.slice(bodyStart, i - 1).trim();
687
+ if (caseKey) {
688
+ caseMap[caseKey] = caseText;
689
+ }
485
690
  }
486
- replacement = caseMap[value] ?? caseMap.other ?? "";
487
691
  }
692
+ const rawReplacement = caseMap[value] ?? caseMap.other ?? "";
693
+ replacement = rawReplacement.includes("{") ? this.interpolate(rawReplacement, values) : rawReplacement;
488
694
  } else {
489
695
  const [fieldName, formatterAndArgs] = token.inner.split("|").map((x) => x.trim());
490
696
  const [formatterName, argsStr] = formatterAndArgs ? formatterAndArgs.split(":") : [];
491
- const args = argsStr ? argsStr.split(",") : [];
697
+ const args = argsStr ? argsStr.split(",").map((a) => a.trim()) : [];
492
698
  const val = values[fieldName];
493
- if (formatterName && this.formatters?.[formatterName]) {
494
- replacement = this.formatters[formatterName](val, args);
699
+ if (formatterName) {
700
+ if (this.formatters?.[formatterName]) {
701
+ replacement = this.formatters[formatterName](val, args);
702
+ } else {
703
+ this.config.devMode && console.warn(`[LocL] Formatter "${formatterName}" not found.`);
704
+ replacement = val !== void 0 ? String(val) : token.raw;
705
+ }
495
706
  } else if (val instanceof Date) {
496
707
  replacement = new Intl.DateTimeFormat(this.language).format(val);
497
708
  } else if (typeof val === "number") {
@@ -500,7 +711,7 @@ var LocL = class _LocL {
500
711
  replacement = val !== void 0 ? String(val) : token.raw;
501
712
  }
502
713
  }
503
- output = output.replace(token.raw, replacement);
714
+ output = output.split(token.raw).join(replacement);
504
715
  }
505
716
  return output;
506
717
  }
@@ -530,13 +741,21 @@ var LocL = class _LocL {
530
741
  this.proxyCache.set(obj, proxy);
531
742
  return proxy;
532
743
  }
744
+ isUnsafeObjectKey(key) {
745
+ return key === "__proto__" || key === "constructor" || key === "prototype";
746
+ }
533
747
  };
534
748
 
535
749
  // src/index.ts
536
750
  function initLocL(config) {
537
751
  return new LocL(config);
538
752
  }
753
+ function defineResources(resources) {
754
+ return resources;
755
+ }
539
756
  // Annotate the CommonJS export names for ESM import in node:
540
757
  0 && (module.exports = {
758
+ LocL,
759
+ defineResources,
541
760
  initLocL
542
761
  });