locl-js 1.0.2 → 2.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.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,106 @@ 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.resources[lang]) {
309
+ this.resources[lang] = {};
310
+ }
311
+ const keys = key.split(".");
312
+ let current = this.resources[lang];
313
+ for (let i = 0; i < keys.length - 1; i++) {
314
+ const k = keys[i];
315
+ if (k === "__proto__" || k === "constructor" || k === "prototype") {
316
+ return;
317
+ }
318
+ if (!current[k] || typeof current[k] !== "object") {
319
+ current[k] = {};
320
+ }
321
+ current = current[k];
322
+ }
323
+ const lastKey = keys[keys.length - 1];
324
+ if (lastKey !== "__proto__" && lastKey !== "constructor" && lastKey !== "prototype") {
325
+ current[lastKey] = value;
326
+ }
327
+ this.invalidateCacheForLang(lang);
328
+ }
329
+ /**
330
+ * Deeply merges a resource bundle into the specified language at runtime.
331
+ * @param lang - Target language code.
332
+ * @param bundle - Object of translations to merge.
333
+ */
334
+ addResources(lang, bundle) {
335
+ if (!this.resources[lang]) {
336
+ this.resources[lang] = {};
337
+ }
338
+ const deepMerge = (target, source) => {
339
+ for (const k of Object.keys(source)) {
340
+ if (k === "__proto__" || k === "constructor" || k === "prototype") {
341
+ continue;
342
+ }
343
+ if (source[k] && typeof source[k] === "object" && !Array.isArray(source[k])) {
344
+ if (!target[k] || typeof target[k] !== "object") {
345
+ target[k] = {};
346
+ }
347
+ deepMerge(target[k], source[k]);
348
+ } else {
349
+ target[k] = source[k];
350
+ }
351
+ }
352
+ };
353
+ deepMerge(this.resources[lang], bundle);
354
+ this.invalidateCacheForLang(lang);
355
+ }
256
356
  /**
257
357
  * Changes the current language of the translator.
258
358
  * @param lang - The new language to set.
259
359
  */
260
360
  changeLanguage(lang) {
261
- if (Object.keys(this.config.resources).includes(lang)) {
262
- this.language = lang;
263
- this.pluralRules = new Intl.PluralRules(this.language);
361
+ const validated = this.isLanguage(lang);
362
+ if (validated && validated !== this.language) {
363
+ const prev = this.language;
364
+ this.language = validated;
365
+ for (const sub of this.subscribers) {
366
+ try {
367
+ sub(this.language, prev);
368
+ } catch (err) {
369
+ console.error("[LocL] Error in subscriber:", err);
370
+ }
371
+ }
264
372
  }
265
373
  }
266
374
  t(key, values, format) {
@@ -269,6 +377,9 @@ var LocL = class _LocL {
269
377
  }
270
378
  const pluralCheckResult = this.checkPlural(key, values);
271
379
  if (pluralCheckResult !== void 0) {
380
+ if (format && format.formatter) {
381
+ return this.applyFormat(pluralCheckResult, format.formatter, format.args);
382
+ }
272
383
  return pluralCheckResult;
273
384
  }
274
385
  const result = this.lookupWithFallback(key);
@@ -299,45 +410,54 @@ var LocL = class _LocL {
299
410
  format(value, formatter, args) {
300
411
  return this.applyFormat(value, formatter, args);
301
412
  }
413
+ plural(key, values, format) {
414
+ return this.t(key, values, format);
415
+ }
302
416
  /**
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.
417
+ * Translates a key and interpolates rich elements using tag functions.
418
+ * Matches `<tag>content</tag>` in translation templates.
419
+ * @param key - The key to translate.
420
+ * @param tags - An object mapping tag names to functions that transform their inner content.
421
+ * @param values - Values to interpolate into variable placeholders ({name}, {{name}}).
422
+ * @returns An array of chunks (strings and custom rendered tag values).
423
+ * @example
424
+ * ```ts
425
+ * translator.rich("terms", {
426
+ * link: (content) => `<a href="/terms">${content}</a>`
427
+ * }, { name: "Alice" });
428
+ * ```
309
429
  */
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;
430
+ rich(key, tags, values) {
431
+ const rawStr = this.t(key, values);
432
+ if (typeof rawStr !== "string") {
433
+ return [rawStr];
434
+ }
435
+ const tagRegex = /<([a-zA-Z0-9_-]+)>([\s\S]*?)<\/\1>/g;
436
+ const result = [];
437
+ let lastIndex = 0;
438
+ let match;
439
+ while ((match = tagRegex.exec(rawStr)) !== null) {
440
+ if (match.index > lastIndex) {
441
+ result.push(rawStr.slice(lastIndex, match.index));
324
442
  }
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;
443
+ const tagName = match[1];
444
+ const content = match[2];
445
+ const renderer = tags[tagName];
446
+ typeof renderer === "function" ? result.push(renderer(content)) : result.push(match[0]);
447
+ lastIndex = match.index + match[0].length;
329
448
  }
330
- if (format && format.formatter) {
331
- return this.applyFormat(interpolated, format.formatter, format.args);
449
+ if (lastIndex < rawStr.length) {
450
+ result.push(rawStr.slice(lastIndex));
332
451
  }
333
- return interpolated;
452
+ return result;
334
453
  }
335
454
  get(key) {
336
455
  const base = this.buildTranslationObject(this.language);
337
456
  if (!key) {
338
- return base;
457
+ return base ? this.makeReadOnly(base) : void 0;
339
458
  }
340
- return this.lookupWithFallback(key, base);
459
+ const val = this.lookupWithFallback(key, base);
460
+ return val && typeof val === "object" ? this.makeReadOnly(val) : val;
341
461
  }
342
462
  /**
343
463
  * Gets a nested object from the translations.
@@ -352,12 +472,27 @@ var LocL = class _LocL {
352
472
  if (!value) {
353
473
  return null;
354
474
  }
355
- const language = value.substring(0, 2);
356
- return Object.keys(this.config.resources).includes(language) ? language : null;
475
+ const available = Object.keys(this.resources);
476
+ if (available.includes(value)) {
477
+ return value;
478
+ }
479
+ const prefix = value.substring(0, 2);
480
+ if (available.includes(prefix)) {
481
+ return prefix;
482
+ }
483
+ return null;
357
484
  }
358
485
  getCacheKey(language) {
359
- const scopeKey = Array.isArray(this.scope) ? this.scope.join("|") : this.scope ?? "*";
360
- return `${language ?? this.language}::${scopeKey}`;
486
+ const scopeKey = Array.isArray(this.scope) ? this.scope.join("|") : this.scope;
487
+ return `${language}::${scopeKey}`;
488
+ }
489
+ invalidateCacheForLang(lang) {
490
+ const prefix = `${lang}::`;
491
+ for (const key of this.cache.keys()) {
492
+ if (key.startsWith(prefix)) {
493
+ this.cache.delete(key);
494
+ }
495
+ }
361
496
  }
362
497
  lookupWithFallback(key, base) {
363
498
  const keys = key.split(".");
@@ -375,7 +510,7 @@ var LocL = class _LocL {
375
510
  return result;
376
511
  }
377
512
  buildTranslationObject(language) {
378
- const langObject = this.config.resources[language];
513
+ const langObject = this.resources[language];
379
514
  if (!this.scope) {
380
515
  return langObject;
381
516
  }
@@ -406,11 +541,10 @@ var LocL = class _LocL {
406
541
  }, combined);
407
542
  }
408
543
  }
409
- const roCombined = this.makeReadOnly(combined);
410
544
  if (this.config.useCache) {
411
- this.cache.set(cacheKey, roCombined);
545
+ this.cache.set(cacheKey, combined);
412
546
  }
413
- return roCombined;
547
+ return combined;
414
548
  }
415
549
  if (this.config.useCache) {
416
550
  this.cache.set(cacheKey, void 0);
@@ -440,13 +574,54 @@ var LocL = class _LocL {
440
574
  }
441
575
  return current;
442
576
  }
577
+ getPluralRules(lang) {
578
+ let rules = this.pluralRulesCache.get(lang);
579
+ if (!rules) {
580
+ rules = new Intl.PluralRules(lang);
581
+ this.pluralRulesCache.set(lang, rules);
582
+ }
583
+ return rules;
584
+ }
443
585
  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);
586
+ if (values?.count !== void 0 && !key.match(/_(zero|one|two|few|many|other)$/)) {
587
+ const category = this.getPluralRules(this.language).select(values.count);
588
+ const baseValue = this.lookupWithFallback(key, void 0);
589
+ if (baseValue && typeof baseValue === "object") {
590
+ if (values.count === 0 && "zero" in baseValue) {
591
+ return this.interpolate(baseValue.zero, values);
592
+ }
593
+ if (category in baseValue) {
594
+ return this.interpolate(baseValue[category], values);
595
+ } else if ("other" in baseValue) {
596
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
597
+ return this.interpolate(baseValue.other, values);
598
+ } else {
599
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
600
+ return key;
601
+ }
602
+ }
603
+ if (values.count === 0) {
604
+ const zeroResult = this.lookupWithFallback(`${key}_zero`, void 0);
605
+ if (zeroResult !== void 0) {
606
+ return this.interpolate(zeroResult, values);
607
+ }
608
+ }
609
+ const categoryResult = this.lookupWithFallback(`${key}_${category}`, void 0);
610
+ if (categoryResult !== void 0) {
611
+ return this.interpolate(categoryResult, values);
612
+ }
613
+ const otherResult = this.lookupWithFallback(`${key}_other`, void 0);
614
+ if (otherResult !== void 0) {
615
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
616
+ return this.interpolate(otherResult, values);
617
+ }
618
+ const anySuffixExists = ["zero", "one", "two", "few", "many"].some((cat) => this.lookupWithFallback(`${key}_${cat}`, void 0) !== void 0);
619
+ if (anySuffixExists) {
620
+ this.config.devMode && console.warn(`[LocL] Missing plural: "${key}" in "${this.language}"`);
621
+ return key;
622
+ }
449
623
  }
624
+ return void 0;
450
625
  }
451
626
  interpolate(result, values) {
452
627
  if (!values || typeof result !== "string") {
@@ -464,34 +639,66 @@ var LocL = class _LocL {
464
639
  depth--;
465
640
  if (depth === 0 && start >= 0) {
466
641
  const raw = result.slice(start, i + 1);
467
- tokens.push({ raw, inner: raw.slice(1, -1).trim() });
642
+ let inner = raw.slice(1, -1);
643
+ if (inner.startsWith("{") && inner.endsWith("}")) {
644
+ inner = inner.slice(1, -1);
645
+ }
646
+ tokens.push({ raw, inner });
468
647
  start = -1;
469
648
  }
470
649
  }
471
650
  }
472
651
  let output = result;
652
+ const processed = /* @__PURE__ */ new Set();
473
653
  for (const token of tokens) {
654
+ if (processed.has(token.raw)) {
655
+ continue;
656
+ }
657
+ processed.add(token.raw);
474
658
  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();
659
+ const selectMatch = token.inner.match(/^\s*([^,]+),\s*select\s*,\s*([\s\S]+)$/);
660
+ if (selectMatch) {
661
+ const field = selectMatch[1].trim();
662
+ const casesBody = selectMatch[2];
663
+ const value = String(values[field] ?? "other");
664
+ const caseMap = {};
665
+ let i = 0;
666
+ while (i < casesBody.length) {
667
+ while (i < casesBody.length && /\s/.test(casesBody[i])) i++;
668
+ if (i >= casesBody.length) break;
669
+ let keyStart = i;
670
+ while (i < casesBody.length && casesBody[i] !== "{" && !/\s/.test(casesBody[i])) i++;
671
+ const caseKey = casesBody.slice(keyStart, i).trim();
672
+ while (i < casesBody.length && casesBody[i] !== "{") i++;
673
+ if (casesBody[i] === "{") {
674
+ let bodyDepth = 1;
675
+ let bodyStart = i + 1;
676
+ i++;
677
+ while (i < casesBody.length && bodyDepth > 0) {
678
+ if (casesBody[i] === "{") bodyDepth++;
679
+ else if (casesBody[i] === "}") bodyDepth--;
680
+ i++;
681
+ }
682
+ const caseText = casesBody.slice(bodyStart, i - 1).trim();
683
+ if (caseKey) {
684
+ caseMap[caseKey] = caseText;
685
+ }
485
686
  }
486
- replacement = caseMap[value] ?? caseMap.other ?? "";
487
687
  }
688
+ const rawReplacement = caseMap[value] ?? caseMap.other ?? "";
689
+ replacement = rawReplacement.includes("{") ? this.interpolate(rawReplacement, values) : rawReplacement;
488
690
  } else {
489
691
  const [fieldName, formatterAndArgs] = token.inner.split("|").map((x) => x.trim());
490
692
  const [formatterName, argsStr] = formatterAndArgs ? formatterAndArgs.split(":") : [];
491
- const args = argsStr ? argsStr.split(",") : [];
693
+ const args = argsStr ? argsStr.split(",").map((a) => a.trim()) : [];
492
694
  const val = values[fieldName];
493
- if (formatterName && this.formatters?.[formatterName]) {
494
- replacement = this.formatters[formatterName](val, args);
695
+ if (formatterName) {
696
+ if (this.formatters?.[formatterName]) {
697
+ replacement = this.formatters[formatterName](val, args);
698
+ } else {
699
+ this.config.devMode && console.warn(`[LocL] Formatter "${formatterName}" not found.`);
700
+ replacement = val !== void 0 ? String(val) : token.raw;
701
+ }
495
702
  } else if (val instanceof Date) {
496
703
  replacement = new Intl.DateTimeFormat(this.language).format(val);
497
704
  } else if (typeof val === "number") {
@@ -500,7 +707,7 @@ var LocL = class _LocL {
500
707
  replacement = val !== void 0 ? String(val) : token.raw;
501
708
  }
502
709
  }
503
- output = output.replace(token.raw, replacement);
710
+ output = output.split(token.raw).join(replacement);
504
711
  }
505
712
  return output;
506
713
  }
@@ -536,7 +743,12 @@ var LocL = class _LocL {
536
743
  function initLocL(config) {
537
744
  return new LocL(config);
538
745
  }
746
+ function defineResources(resources) {
747
+ return resources;
748
+ }
539
749
  // Annotate the CommonJS export names for ESM import in node:
540
750
  0 && (module.exports = {
751
+ LocL,
752
+ defineResources,
541
753
  initLocL
542
754
  });