@coherent.js/i18n 1.1.0 → 2.0.0-rc.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/README.md +56 -2
- package/dist/formatters.js +30 -23
- package/dist/formatters.js.map +2 -2
- package/dist/index.js +183 -52
- package/dist/index.js.map +2 -2
- package/dist/translator.js +152 -28
- package/dist/translator.js.map +2 -2
- package/package.json +1 -4
- package/types/index.d.ts +99 -10
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@coherent.js/i18n)
|
|
4
4
|
[](../../LICENSE)
|
|
5
|
-
[](https://nodejs.org)
|
|
6
6
|
|
|
7
7
|
Internationalization utilities for Coherent.js applications.
|
|
8
8
|
|
|
9
|
-
- ESM-only, Node
|
|
9
|
+
- ESM-only, Node 22.12+
|
|
10
10
|
- Translator + locale management
|
|
11
11
|
- Date/number/currency/list formatters
|
|
12
12
|
|
|
@@ -49,6 +49,35 @@ translator.setLocale('fr');
|
|
|
49
49
|
console.log(translator.t('hello', { name: 'Coherent' })); // Bonjour, Coherent !
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
`setLocale('fr-FR')` resolves to the closest loaded locale (`fr` here); a key
|
|
53
|
+
missing from a regional locale is looked up in its language, then in the
|
|
54
|
+
fallback locale.
|
|
55
|
+
|
|
56
|
+
### Server-side rendering: one translator per request
|
|
57
|
+
|
|
58
|
+
`setLocale()` changes the shared instance's current locale, so on a server
|
|
59
|
+
where concurrent requests render with the same translator, one request's
|
|
60
|
+
locale leaks into another's output. Keep `setLocale()` for the browser and
|
|
61
|
+
bind a translator to each request instead:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
// Created once at startup
|
|
65
|
+
const i18n = createTranslator({ defaultLocale: 'en' });
|
|
66
|
+
i18n.addTranslations('en', { hello: 'Hello, {{name}}!' });
|
|
67
|
+
i18n.addTranslations('fr', { hello: 'Bonjour, {{name}} !' });
|
|
68
|
+
|
|
69
|
+
// Per request — never mutates `i18n`
|
|
70
|
+
app.get('/', (req, res) => {
|
|
71
|
+
const { t } = i18n.forLocale(req.acceptsLanguages('en', 'fr') || 'en');
|
|
72
|
+
res.send(render({ p: { text: t('hello', { name: req.query.name }) } }));
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`forLocale(locale)` resolves the locale like `setLocale()` (falling back to
|
|
77
|
+
the fallback locale without a warning, since request locales are untrusted)
|
|
78
|
+
and returns `{ locale, t, has, getLocale }`. Pass `{ escape: true }` as a
|
|
79
|
+
second argument to escape params in every call.
|
|
80
|
+
|
|
52
81
|
TypeScript:
|
|
53
82
|
```ts
|
|
54
83
|
import { createTranslator } from '@coherent.js/i18n';
|
|
@@ -59,6 +88,31 @@ translator.addTranslations('en', { hello: 'Hello, {{name}}!' });
|
|
|
59
88
|
console.log(translator.t('hello', { name: 'TS' }));
|
|
60
89
|
```
|
|
61
90
|
|
|
91
|
+
### Rendering translations safely
|
|
92
|
+
|
|
93
|
+
Interpolated params are inserted verbatim by default. Render translations
|
|
94
|
+
through core's `text:` property, which HTML-escapes the whole string — that is
|
|
95
|
+
the safe sink:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
{ p: { text: translator.t('hello', { name: userInput }) } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If a translation contains markup and has to go through `html:`, escape the
|
|
102
|
+
params (the translation template itself is trusted and never escaped), either
|
|
103
|
+
per call or for every call:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
translator.addTranslations('en', { joined: '<strong>{{name}}</strong> joined' });
|
|
107
|
+
|
|
108
|
+
{ p: { html: translator.t('joined', { name: userInput }, { escape: true }) } }
|
|
109
|
+
|
|
110
|
+
const safe = createTranslator({ escape: true }); // escape params on every call
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The third argument of `t()` is either a locale string or
|
|
114
|
+
`{ locale, escape }`.
|
|
115
|
+
|
|
62
116
|
### Formatters and locale
|
|
63
117
|
|
|
64
118
|
```js
|
package/dist/formatters.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
// src/formatters.js
|
|
2
|
+
var RELATIVE_TIME_UNITS = [
|
|
3
|
+
["year", 365 * 24 * 60 * 60],
|
|
4
|
+
["month", 30 * 24 * 60 * 60],
|
|
5
|
+
["week", 7 * 24 * 60 * 60],
|
|
6
|
+
["day", 24 * 60 * 60],
|
|
7
|
+
["hour", 60 * 60],
|
|
8
|
+
["minute", 60]
|
|
9
|
+
];
|
|
2
10
|
var DateFormatter = class {
|
|
3
11
|
constructor(locale = "en") {
|
|
4
12
|
this.locale = locale;
|
|
@@ -83,37 +91,36 @@ var DateFormatter = class {
|
|
|
83
91
|
});
|
|
84
92
|
}
|
|
85
93
|
/**
|
|
86
|
-
* Format relative time (e.g., "2 days ago")
|
|
94
|
+
* Format relative time (e.g., "2 days ago", "tomorrow", "in 3 weeks")
|
|
95
|
+
*
|
|
96
|
+
* Works for past and future dates and uses the largest unit that fits:
|
|
97
|
+
* seconds, minutes, hours, days, weeks (from 7 days), months (from 30 days)
|
|
98
|
+
* or years (from 365 days). Values are truncated toward zero, so 36 hours
|
|
99
|
+
* ago is "yesterday".
|
|
87
100
|
*/
|
|
88
101
|
relative(date) {
|
|
89
102
|
const dateObj = date instanceof Date ? date : new Date(date);
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
103
|
+
const diffSec = Math.round((dateObj.getTime() - Date.now()) / 1e3);
|
|
104
|
+
const absSec = Math.abs(diffSec);
|
|
105
|
+
let unit = "second";
|
|
106
|
+
let amount = absSec;
|
|
107
|
+
for (const [name, seconds] of RELATIVE_TIME_UNITS) {
|
|
108
|
+
if (absSec >= seconds) {
|
|
109
|
+
unit = name;
|
|
110
|
+
amount = Math.floor(absSec / seconds);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const value = diffSec < 0 ? -amount : amount;
|
|
96
115
|
if (typeof Intl !== "undefined" && Intl.RelativeTimeFormat) {
|
|
97
116
|
const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto" });
|
|
98
|
-
|
|
99
|
-
return rtf.format(-diffDay, "day");
|
|
100
|
-
} else if (diffHour > 0) {
|
|
101
|
-
return rtf.format(-diffHour, "hour");
|
|
102
|
-
} else if (diffMin > 0) {
|
|
103
|
-
return rtf.format(-diffMin, "minute");
|
|
104
|
-
} else {
|
|
105
|
-
return rtf.format(-diffSec, "second");
|
|
106
|
-
}
|
|
117
|
+
return rtf.format(value, unit);
|
|
107
118
|
}
|
|
108
|
-
if (
|
|
109
|
-
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
|
110
|
-
} else if (diffHour > 0) {
|
|
111
|
-
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
|
|
112
|
-
} else if (diffMin > 0) {
|
|
113
|
-
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
|
114
|
-
} else {
|
|
119
|
+
if (unit === "second") {
|
|
115
120
|
return "just now";
|
|
116
121
|
}
|
|
122
|
+
const label = `${amount} ${unit}${amount > 1 ? "s" : ""}`;
|
|
123
|
+
return value < 0 ? `${label} ago` : `in ${label}`;
|
|
117
124
|
}
|
|
118
125
|
};
|
|
119
126
|
var NumberFormatter = class {
|
package/dist/formatters.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/formatters.js"],
|
|
4
|
-
"sourcesContent": ["/**\n * Coherent.js I18n Formatters\n * \n * Locale-aware formatting for dates, numbers, and currencies\n * \n * @module i18n/formatters\n */\n\n/**\n * Date Formatter\n * Formats dates according to locale\n */\nexport class DateFormatter {\n constructor(locale = 'en') {\n this.locale = locale;\n }\n\n /**\n * Format a date\n * \n * @param {Date|string|number} date - Date to format\n * @param {Object} [options] - Intl.DateTimeFormat options\n * @returns {string} Formatted date\n */\n format(date, options = {}) {\n const dateObj = date instanceof Date ? date : new Date(date);\n \n if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {\n const formatter = new Intl.DateTimeFormat(this.locale, options);\n return formatter.format(dateObj);\n }\n \n // Fallback\n return dateObj.toLocaleDateString();\n }\n\n /**\n * Format date as short (e.g., 1/1/2024)\n */\n short(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'numeric',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as medium (e.g., Jan 1, 2024)\n */\n medium(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'short',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as long (e.g., January 1, 2024)\n */\n long(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as full (e.g., Monday, January 1, 2024)\n */\n full(date) {\n return this.format(date, {\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n });\n }\n\n /**\n * Format time\n */\n time(date, options = {}) {\n return this.format(date, {\n hour: 'numeric',\n minute: 'numeric',\n ...options\n });\n }\n\n /**\n * Format date and time\n */\n dateTime(date, options = {}) {\n return this.format(date, {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n ...options\n });\n }\n\n /**\n * Format relative time (e.g., \"2 days ago\")\n */\n relative(date) {\n const dateObj = date instanceof Date ? date : new Date(date);\n
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["/**\n * Coherent.js I18n Formatters\n * \n * Locale-aware formatting for dates, numbers, and currencies\n * \n * @module i18n/formatters\n */\n\n/**\n * Units for `DateFormatter#relative`, largest first, with their length in\n * seconds (months and years approximated as 30 and 365 days).\n */\nconst RELATIVE_TIME_UNITS = [\n ['year', 365 * 24 * 60 * 60],\n ['month', 30 * 24 * 60 * 60],\n ['week', 7 * 24 * 60 * 60],\n ['day', 24 * 60 * 60],\n ['hour', 60 * 60],\n ['minute', 60]\n];\n\n/**\n * Date Formatter\n * Formats dates according to locale\n */\nexport class DateFormatter {\n constructor(locale = 'en') {\n this.locale = locale;\n }\n\n /**\n * Format a date\n * \n * @param {Date|string|number} date - Date to format\n * @param {Object} [options] - Intl.DateTimeFormat options\n * @returns {string} Formatted date\n */\n format(date, options = {}) {\n const dateObj = date instanceof Date ? date : new Date(date);\n \n if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {\n const formatter = new Intl.DateTimeFormat(this.locale, options);\n return formatter.format(dateObj);\n }\n \n // Fallback\n return dateObj.toLocaleDateString();\n }\n\n /**\n * Format date as short (e.g., 1/1/2024)\n */\n short(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'numeric',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as medium (e.g., Jan 1, 2024)\n */\n medium(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'short',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as long (e.g., January 1, 2024)\n */\n long(date) {\n return this.format(date, {\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n });\n }\n\n /**\n * Format date as full (e.g., Monday, January 1, 2024)\n */\n full(date) {\n return this.format(date, {\n weekday: 'long',\n year: 'numeric',\n month: 'long',\n day: 'numeric'\n });\n }\n\n /**\n * Format time\n */\n time(date, options = {}) {\n return this.format(date, {\n hour: 'numeric',\n minute: 'numeric',\n ...options\n });\n }\n\n /**\n * Format date and time\n */\n dateTime(date, options = {}) {\n return this.format(date, {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: 'numeric',\n ...options\n });\n }\n\n /**\n * Format relative time (e.g., \"2 days ago\", \"tomorrow\", \"in 3 weeks\")\n *\n * Works for past and future dates and uses the largest unit that fits:\n * seconds, minutes, hours, days, weeks (from 7 days), months (from 30 days)\n * or years (from 365 days). Values are truncated toward zero, so 36 hours\n * ago is \"yesterday\".\n */\n relative(date) {\n const dateObj = date instanceof Date ? date : new Date(date);\n // Negative for the past, positive for the future. Rounded to the second so\n // the milliseconds elapsed since the caller computed `date` do not turn\n // \"tomorrow\" into \"in 23 hours\".\n const diffSec = Math.round((dateObj.getTime() - Date.now()) / 1000);\n const absSec = Math.abs(diffSec);\n\n let unit = 'second';\n let amount = absSec;\n for (const [name, seconds] of RELATIVE_TIME_UNITS) {\n if (absSec >= seconds) {\n unit = name;\n amount = Math.floor(absSec / seconds);\n break;\n }\n }\n const value = diffSec < 0 ? -amount : amount;\n\n if (typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {\n const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' });\n return rtf.format(value, unit);\n }\n\n // Fallback\n if (unit === 'second') {\n return 'just now';\n }\n const label = `${amount} ${unit}${amount > 1 ? 's' : ''}`;\n return value < 0 ? `${label} ago` : `in ${label}`;\n }\n}\n\n/**\n * Number Formatter\n * Formats numbers according to locale\n */\nexport class NumberFormatter {\n constructor(locale = 'en') {\n this.locale = locale;\n }\n\n /**\n * Format a number\n * \n * @param {number} value - Number to format\n * @param {Object} [options] - Intl.NumberFormat options\n * @returns {string} Formatted number\n */\n format(value, options = {}) {\n if (typeof Intl !== 'undefined' && Intl.NumberFormat) {\n const formatter = new Intl.NumberFormat(this.locale, options);\n return formatter.format(value);\n }\n \n // Fallback\n return value.toLocaleString();\n }\n\n /**\n * Format as decimal\n */\n decimal(value, decimals = 2) {\n return this.format(value, {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals\n });\n }\n\n /**\n * Format as percentage\n */\n percent(value, decimals = 0) {\n return this.format(value, {\n style: 'percent',\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals\n });\n }\n\n /**\n * Format as compact (e.g., 1.2K, 3.4M)\n */\n compact(value) {\n if (typeof Intl !== 'undefined' && Intl.NumberFormat) {\n try {\n const formatter = new Intl.NumberFormat(this.locale, {\n notation: 'compact',\n compactDisplay: 'short'\n });\n return formatter.format(value);\n } catch {\n // Fallback for older browsers\n }\n }\n \n // Manual compact formatting\n if (value >= 1e9) {\n return `${(value / 1e9).toFixed(1) }B`;\n } else if (value >= 1e6) {\n return `${(value / 1e6).toFixed(1) }M`;\n } else if (value >= 1e3) {\n return `${(value / 1e3).toFixed(1) }K`;\n }\n \n return String(value);\n }\n\n /**\n * Format with units\n */\n unit(value, unit, options = {}) {\n if (typeof Intl !== 'undefined' && Intl.NumberFormat) {\n try {\n const formatter = new Intl.NumberFormat(this.locale, {\n style: 'unit',\n unit,\n ...options\n });\n return formatter.format(value);\n } catch {\n // Fallback\n }\n }\n \n return `${value} ${unit}`;\n }\n}\n\n/**\n * Currency Formatter\n * Formats currency values according to locale\n */\nexport class CurrencyFormatter {\n constructor(locale = 'en', defaultCurrency = 'USD') {\n this.locale = locale;\n this.defaultCurrency = defaultCurrency;\n }\n\n /**\n * Format a currency value\n * \n * @param {number} value - Amount to format\n * @param {string} [currency] - Currency code (e.g., 'USD', 'EUR')\n * @param {Object} [options] - Additional options\n * @returns {string} Formatted currency\n */\n format(value, currency = null, options = {}) {\n const currencyCode = currency || this.defaultCurrency;\n \n if (typeof Intl !== 'undefined' && Intl.NumberFormat) {\n const formatter = new Intl.NumberFormat(this.locale, {\n style: 'currency',\n currency: currencyCode,\n ...options\n });\n return formatter.format(value);\n }\n \n // Fallback\n return `${currencyCode} ${value.toFixed(2)}`;\n }\n\n /**\n * Format without decimal places\n */\n whole(value, currency = null) {\n return this.format(value, currency, {\n minimumFractionDigits: 0,\n maximumFractionDigits: 0\n });\n }\n\n /**\n * Format with symbol only (no code)\n */\n symbol(value, currency = null) {\n return this.format(value, currency, {\n currencyDisplay: 'symbol'\n });\n }\n\n /**\n * Format with narrow symbol\n */\n narrowSymbol(value, currency = null) {\n return this.format(value, currency, {\n currencyDisplay: 'narrowSymbol'\n });\n }\n\n /**\n * Format with code (e.g., USD 100.00)\n */\n code(value, currency = null) {\n return this.format(value, currency, {\n currencyDisplay: 'code'\n });\n }\n}\n\n/**\n * List Formatter\n * Formats lists according to locale\n */\nexport class ListFormatter {\n constructor(locale = 'en') {\n this.locale = locale;\n }\n\n /**\n * Format a list\n * \n * @param {Array} items - Items to format\n * @param {Object} [options] - Formatting options\n * @returns {string} Formatted list\n */\n format(items, options = {}) {\n if (typeof Intl !== 'undefined' && Intl.ListFormat) {\n const formatter = new Intl.ListFormat(this.locale, options);\n return formatter.format(items);\n }\n \n // Fallback\n if (items.length === 0) return '';\n if (items.length === 1) return items[0];\n if (items.length === 2) return `${items[0]} and ${items[1]}`;\n \n const last = items[items.length - 1];\n const rest = items.slice(0, -1);\n return `${rest.join(', ')}, and ${last}`;\n }\n\n /**\n * Format as conjunction (and)\n */\n and(items) {\n return this.format(items, { type: 'conjunction' });\n }\n\n /**\n * Format as disjunction (or)\n */\n or(items) {\n return this.format(items, { type: 'disjunction' });\n }\n\n /**\n * Format as unit list\n */\n unit(items) {\n return this.format(items, { type: 'unit' });\n }\n}\n\n/**\n * Create formatters for a locale\n * \n * @param {string} locale - Locale code\n * @param {Object} [options] - Formatter options\n * @returns {Object} Formatter instances\n */\nexport function createFormatters(locale = 'en', options = {}) {\n return {\n date: new DateFormatter(locale),\n number: new NumberFormatter(locale),\n currency: new CurrencyFormatter(locale, options.defaultCurrency),\n list: new ListFormatter(locale)\n };\n}\n\nexport default {\n DateFormatter,\n NumberFormatter,\n CurrencyFormatter,\n ListFormatter,\n createFormatters\n};\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
// src/translator.js
|
|
2
|
+
function escapeRegExp(value) {
|
|
3
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
var HTML_ESCAPES = {
|
|
6
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
'"': """,
|
|
10
|
+
"'": "'"
|
|
11
|
+
};
|
|
12
|
+
function escapeHtml(value) {
|
|
13
|
+
return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
14
|
+
}
|
|
15
|
+
function primaryLanguage(locale) {
|
|
16
|
+
return String(locale).split(/[-_]/)[0].toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
function normalizeCallOptions(localeOrOptions) {
|
|
19
|
+
if (localeOrOptions !== null && typeof localeOrOptions === "object") {
|
|
20
|
+
return localeOrOptions;
|
|
21
|
+
}
|
|
22
|
+
return { locale: localeOrOptions };
|
|
23
|
+
}
|
|
2
24
|
var Translator = class {
|
|
3
25
|
constructor(options = {}) {
|
|
26
|
+
const { interpolation, ...rest } = options;
|
|
4
27
|
this.options = {
|
|
5
28
|
defaultLocale: "en",
|
|
6
29
|
fallbackLocale: "en",
|
|
7
30
|
missingKeyHandler: null,
|
|
31
|
+
// HTML-escape interpolated params (never the translation itself).
|
|
32
|
+
escape: false,
|
|
33
|
+
...rest,
|
|
34
|
+
// Merged, so overriding only `prefix` keeps the default `suffix`.
|
|
8
35
|
interpolation: {
|
|
9
36
|
prefix: "{{",
|
|
10
|
-
suffix: "}}"
|
|
11
|
-
|
|
12
|
-
|
|
37
|
+
suffix: "}}",
|
|
38
|
+
...interpolation
|
|
39
|
+
}
|
|
13
40
|
};
|
|
14
41
|
this.translations = /* @__PURE__ */ new Map();
|
|
15
42
|
this.currentLocale = this.options.defaultLocale;
|
|
@@ -43,17 +70,56 @@ var Translator = class {
|
|
|
43
70
|
}
|
|
44
71
|
return result;
|
|
45
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Loaded locales that can serve `locale`, most specific first: the locale
|
|
75
|
+
* itself, then its parents with trailing subtags dropped (`zh-Hant-TW` →
|
|
76
|
+
* `zh-Hant` → `zh`). Matching ignores case and accepts `_` for `-`.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} locale
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
localeCandidates(locale) {
|
|
82
|
+
if (typeof locale !== "string" || locale === "") return [];
|
|
83
|
+
const byLowerCase = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const loaded of this.loadedLocales) {
|
|
85
|
+
byLowerCase.set(String(loaded).toLowerCase(), loaded);
|
|
86
|
+
}
|
|
87
|
+
const candidates = [];
|
|
88
|
+
const parts = locale.replace(/_/g, "-").toLowerCase().split("-");
|
|
89
|
+
while (parts.length > 0) {
|
|
90
|
+
const match = byLowerCase.get(parts.join("-"));
|
|
91
|
+
if (match !== void 0 && !candidates.includes(match)) {
|
|
92
|
+
candidates.push(match);
|
|
93
|
+
}
|
|
94
|
+
parts.pop();
|
|
95
|
+
}
|
|
96
|
+
return candidates;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a requested locale to a loaded one (`fr-FR` → `fr` when only
|
|
100
|
+
* `fr` is loaded), or `null` when neither it nor a parent is loaded.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} locale
|
|
103
|
+
* @returns {string|null}
|
|
104
|
+
*/
|
|
105
|
+
resolveLocale(locale) {
|
|
106
|
+
return this.localeCandidates(locale)[0] ?? null;
|
|
107
|
+
}
|
|
46
108
|
/**
|
|
47
109
|
* Set current locale
|
|
48
|
-
*
|
|
110
|
+
*
|
|
111
|
+
* Resolves to the closest loaded locale (`fr-FR` → `fr`); if neither it nor
|
|
112
|
+
* a parent is loaded, the fallback locale is used.
|
|
113
|
+
*
|
|
49
114
|
* @param {string} locale - Locale code
|
|
50
115
|
*/
|
|
51
116
|
setLocale(locale) {
|
|
52
|
-
|
|
117
|
+
const resolved = this.resolveLocale(locale);
|
|
118
|
+
if (resolved === null) {
|
|
53
119
|
console.warn(`Locale ${locale} not loaded, using fallback`);
|
|
54
120
|
this.currentLocale = this.options.fallbackLocale;
|
|
55
121
|
} else {
|
|
56
|
-
this.currentLocale =
|
|
122
|
+
this.currentLocale = resolved;
|
|
57
123
|
}
|
|
58
124
|
}
|
|
59
125
|
/**
|
|
@@ -64,19 +130,66 @@ var Translator = class {
|
|
|
64
130
|
getLocale() {
|
|
65
131
|
return this.currentLocale;
|
|
66
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Get a translator bound to one locale, without touching the shared
|
|
135
|
+
* instance's current locale.
|
|
136
|
+
*
|
|
137
|
+
* Use this on the server: `setLocale()` mutates `currentLocale`, which every
|
|
138
|
+
* concurrent request rendering with the same instance shares, so one
|
|
139
|
+
* request's locale leaks into another's output. A bound translator always
|
|
140
|
+
* passes its own locale. It reads the shared translations live, so
|
|
141
|
+
* translations added later are visible.
|
|
142
|
+
*
|
|
143
|
+
* The locale resolves like `setLocale()` (`fr-FR` → `fr`), falling back to
|
|
144
|
+
* the fallback locale — silently, since request locales are untrusted input.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} locale - Requested locale (e.g. from Accept-Language)
|
|
147
|
+
* @param {{escape?: boolean}} [options] - `escape` default for this
|
|
148
|
+
* translator's calls (defaults to the shared instance's `escape`)
|
|
149
|
+
* @returns {{ locale: string, t: Function, has: Function, getLocale: () => string }}
|
|
150
|
+
*/
|
|
151
|
+
forLocale(locale, options = {}) {
|
|
152
|
+
const boundLocale = this.resolveLocale(locale) ?? this.options.fallbackLocale;
|
|
153
|
+
return {
|
|
154
|
+
locale: boundLocale,
|
|
155
|
+
t: (key, params = {}, localeOrOptions = null) => {
|
|
156
|
+
const callOptions = normalizeCallOptions(localeOrOptions);
|
|
157
|
+
return this.t(key, params, {
|
|
158
|
+
locale: callOptions.locale || boundLocale,
|
|
159
|
+
escape: callOptions.escape ?? options.escape ?? this.options.escape
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
has: (key, override = null) => this.has(key, override || boundLocale),
|
|
163
|
+
getLocale: () => boundLocale
|
|
164
|
+
};
|
|
165
|
+
}
|
|
67
166
|
/**
|
|
68
167
|
* Translate a key
|
|
69
|
-
*
|
|
168
|
+
*
|
|
70
169
|
* @param {string} key - Translation key (supports dot notation)
|
|
71
170
|
* @param {Object} [params] - Interpolation parameters
|
|
72
|
-
* @param {string} [
|
|
171
|
+
* @param {string|{locale?: string|null, escape?: boolean}|null} [localeOrOptions]
|
|
172
|
+
* Override locale, or `{ locale, escape }`. `escape: true` HTML-escapes the
|
|
173
|
+
* interpolated params (defaults to the translator's `escape` option).
|
|
73
174
|
* @returns {string} Translated string
|
|
74
175
|
*/
|
|
75
|
-
t(key, params = {},
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
176
|
+
t(key, params = {}, localeOrOptions = null) {
|
|
177
|
+
const callOptions = normalizeCallOptions(localeOrOptions);
|
|
178
|
+
const targetLocale = callOptions.locale || this.currentLocale;
|
|
179
|
+
const escape = callOptions.escape ?? this.options.escape;
|
|
180
|
+
params = params || {};
|
|
181
|
+
const chain = [
|
|
182
|
+
...this.localeCandidates(targetLocale),
|
|
183
|
+
...this.localeCandidates(this.options.fallbackLocale)
|
|
184
|
+
];
|
|
185
|
+
let translation = null;
|
|
186
|
+
let messageLocale = targetLocale;
|
|
187
|
+
for (const candidate of chain) {
|
|
188
|
+
translation = this.getTranslation(key, candidate);
|
|
189
|
+
if (translation !== null) {
|
|
190
|
+
messageLocale = candidate;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
80
193
|
}
|
|
81
194
|
if (translation === null) {
|
|
82
195
|
if (this.options.missingKeyHandler) {
|
|
@@ -85,10 +198,11 @@ var Translator = class {
|
|
|
85
198
|
return key;
|
|
86
199
|
}
|
|
87
200
|
if (typeof translation === "object" && params.count !== void 0) {
|
|
88
|
-
|
|
201
|
+
const pluralLocale = primaryLanguage(messageLocale) === primaryLanguage(targetLocale) ? targetLocale : messageLocale;
|
|
202
|
+
translation = this.selectPlural(translation, params.count, pluralLocale);
|
|
89
203
|
}
|
|
90
204
|
if (typeof translation === "string") {
|
|
91
|
-
return this.interpolate(translation, params);
|
|
205
|
+
return this.interpolate(translation, params, { escape });
|
|
92
206
|
}
|
|
93
207
|
return String(translation);
|
|
94
208
|
}
|
|
@@ -98,10 +212,10 @@ var Translator = class {
|
|
|
98
212
|
getTranslation(key, locale) {
|
|
99
213
|
const translations = this.translations.get(locale);
|
|
100
214
|
if (!translations) return null;
|
|
101
|
-
const keys = key.split(".");
|
|
215
|
+
const keys = String(key).split(".");
|
|
102
216
|
let value = translations;
|
|
103
217
|
for (const k of keys) {
|
|
104
|
-
if (value && typeof value === "object" && k
|
|
218
|
+
if (value && typeof value === "object" && Object.hasOwn(value, k)) {
|
|
105
219
|
value = value[k];
|
|
106
220
|
} else {
|
|
107
221
|
return null;
|
|
@@ -117,9 +231,12 @@ var Translator = class {
|
|
|
117
231
|
return pluralObject.zero;
|
|
118
232
|
}
|
|
119
233
|
if (typeof Intl !== "undefined" && Intl.PluralRules) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
234
|
+
let rule;
|
|
235
|
+
try {
|
|
236
|
+
rule = new Intl.PluralRules(locale).select(count);
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
if (rule !== void 0 && pluralObject[rule]) {
|
|
123
240
|
return pluralObject[rule];
|
|
124
241
|
}
|
|
125
242
|
}
|
|
@@ -132,15 +249,22 @@ var Translator = class {
|
|
|
132
249
|
}
|
|
133
250
|
/**
|
|
134
251
|
* Interpolate parameters into string
|
|
135
|
-
|
|
136
|
-
|
|
252
|
+
*
|
|
253
|
+
* @param {string} str - Translation template (trusted; never escaped)
|
|
254
|
+
* @param {Object} params - Values for the placeholders
|
|
255
|
+
* @param {{escape?: boolean}} [options] - `escape: true` HTML-escapes each
|
|
256
|
+
* value; defaults to the translator's `escape` option
|
|
257
|
+
*/
|
|
258
|
+
interpolate(str, params, options = {}) {
|
|
259
|
+
if (!params || typeof params !== "object") return str;
|
|
260
|
+
const escape = options.escape ?? this.options.escape;
|
|
261
|
+
const format = escape ? escapeHtml : String;
|
|
262
|
+
const names = Object.keys(params);
|
|
263
|
+
if (names.length === 0) return str;
|
|
137
264
|
const { prefix, suffix } = this.options.interpolation;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
result = result.replace(new RegExp(placeholder, "g"), String(value));
|
|
142
|
-
}
|
|
143
|
-
return result;
|
|
265
|
+
const alternatives = names.sort((a, b) => b.length - a.length).map(escapeRegExp).join("|");
|
|
266
|
+
const pattern = new RegExp(`${escapeRegExp(prefix)}(${alternatives})${escapeRegExp(suffix)}`, "g");
|
|
267
|
+
return str.replace(pattern, (_match, name) => format(params[name]));
|
|
144
268
|
}
|
|
145
269
|
/**
|
|
146
270
|
* Check if translation exists
|
|
@@ -151,7 +275,7 @@ var Translator = class {
|
|
|
151
275
|
*/
|
|
152
276
|
has(key, locale = null) {
|
|
153
277
|
const targetLocale = locale || this.currentLocale;
|
|
154
|
-
return this.getTranslation(key,
|
|
278
|
+
return this.localeCandidates(targetLocale).some((candidate) => this.getTranslation(key, candidate) !== null);
|
|
155
279
|
}
|
|
156
280
|
/**
|
|
157
281
|
* Get all translations for current locale
|
|
@@ -208,6 +332,14 @@ function createScopedTranslator(translator, namespace) {
|
|
|
208
332
|
}
|
|
209
333
|
|
|
210
334
|
// src/formatters.js
|
|
335
|
+
var RELATIVE_TIME_UNITS = [
|
|
336
|
+
["year", 365 * 24 * 60 * 60],
|
|
337
|
+
["month", 30 * 24 * 60 * 60],
|
|
338
|
+
["week", 7 * 24 * 60 * 60],
|
|
339
|
+
["day", 24 * 60 * 60],
|
|
340
|
+
["hour", 60 * 60],
|
|
341
|
+
["minute", 60]
|
|
342
|
+
];
|
|
211
343
|
var DateFormatter = class {
|
|
212
344
|
constructor(locale = "en") {
|
|
213
345
|
this.locale = locale;
|
|
@@ -292,37 +424,36 @@ var DateFormatter = class {
|
|
|
292
424
|
});
|
|
293
425
|
}
|
|
294
426
|
/**
|
|
295
|
-
* Format relative time (e.g., "2 days ago")
|
|
427
|
+
* Format relative time (e.g., "2 days ago", "tomorrow", "in 3 weeks")
|
|
428
|
+
*
|
|
429
|
+
* Works for past and future dates and uses the largest unit that fits:
|
|
430
|
+
* seconds, minutes, hours, days, weeks (from 7 days), months (from 30 days)
|
|
431
|
+
* or years (from 365 days). Values are truncated toward zero, so 36 hours
|
|
432
|
+
* ago is "yesterday".
|
|
296
433
|
*/
|
|
297
434
|
relative(date) {
|
|
298
435
|
const dateObj = date instanceof Date ? date : new Date(date);
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
436
|
+
const diffSec = Math.round((dateObj.getTime() - Date.now()) / 1e3);
|
|
437
|
+
const absSec = Math.abs(diffSec);
|
|
438
|
+
let unit = "second";
|
|
439
|
+
let amount = absSec;
|
|
440
|
+
for (const [name, seconds] of RELATIVE_TIME_UNITS) {
|
|
441
|
+
if (absSec >= seconds) {
|
|
442
|
+
unit = name;
|
|
443
|
+
amount = Math.floor(absSec / seconds);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const value = diffSec < 0 ? -amount : amount;
|
|
305
448
|
if (typeof Intl !== "undefined" && Intl.RelativeTimeFormat) {
|
|
306
449
|
const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto" });
|
|
307
|
-
|
|
308
|
-
return rtf.format(-diffDay, "day");
|
|
309
|
-
} else if (diffHour > 0) {
|
|
310
|
-
return rtf.format(-diffHour, "hour");
|
|
311
|
-
} else if (diffMin > 0) {
|
|
312
|
-
return rtf.format(-diffMin, "minute");
|
|
313
|
-
} else {
|
|
314
|
-
return rtf.format(-diffSec, "second");
|
|
315
|
-
}
|
|
450
|
+
return rtf.format(value, unit);
|
|
316
451
|
}
|
|
317
|
-
if (
|
|
318
|
-
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
|
319
|
-
} else if (diffHour > 0) {
|
|
320
|
-
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
|
|
321
|
-
} else if (diffMin > 0) {
|
|
322
|
-
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
|
323
|
-
} else {
|
|
452
|
+
if (unit === "second") {
|
|
324
453
|
return "just now";
|
|
325
454
|
}
|
|
455
|
+
const label = `${amount} ${unit}${amount > 1 ? "s" : ""}`;
|
|
456
|
+
return value < 0 ? `${label} ago` : `in ${label}`;
|
|
326
457
|
}
|
|
327
458
|
};
|
|
328
459
|
var NumberFormatter = class {
|