@wikytam/helpers 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/LICENSE +21 -0
- package/README.md +574 -0
- package/README_vi.md +519 -0
- package/dist/index.cjs +1147 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +373 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +373 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1137 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/locales/en.ts
|
|
3
|
+
const ones$1 = [
|
|
4
|
+
"",
|
|
5
|
+
"one",
|
|
6
|
+
"two",
|
|
7
|
+
"three",
|
|
8
|
+
"four",
|
|
9
|
+
"five",
|
|
10
|
+
"six",
|
|
11
|
+
"seven",
|
|
12
|
+
"eight",
|
|
13
|
+
"nine",
|
|
14
|
+
"ten",
|
|
15
|
+
"eleven",
|
|
16
|
+
"twelve",
|
|
17
|
+
"thirteen",
|
|
18
|
+
"fourteen",
|
|
19
|
+
"fifteen",
|
|
20
|
+
"sixteen",
|
|
21
|
+
"seventeen",
|
|
22
|
+
"eighteen",
|
|
23
|
+
"nineteen"
|
|
24
|
+
];
|
|
25
|
+
const tens = [
|
|
26
|
+
"",
|
|
27
|
+
"",
|
|
28
|
+
"twenty",
|
|
29
|
+
"thirty",
|
|
30
|
+
"forty",
|
|
31
|
+
"fifty",
|
|
32
|
+
"sixty",
|
|
33
|
+
"seventy",
|
|
34
|
+
"eighty",
|
|
35
|
+
"ninety"
|
|
36
|
+
];
|
|
37
|
+
const digitWords$1 = {
|
|
38
|
+
"0": "zero",
|
|
39
|
+
"1": "one",
|
|
40
|
+
"2": "two",
|
|
41
|
+
"3": "three",
|
|
42
|
+
"4": "four",
|
|
43
|
+
"5": "five",
|
|
44
|
+
"6": "six",
|
|
45
|
+
"7": "seven",
|
|
46
|
+
"8": "eight",
|
|
47
|
+
"9": "nine"
|
|
48
|
+
};
|
|
49
|
+
function convert$1(num) {
|
|
50
|
+
if (num === 0) return "";
|
|
51
|
+
if (num < 20) return ones$1[num] ?? "";
|
|
52
|
+
if (num < 100) {
|
|
53
|
+
const t = tens[Math.floor(num / 10)] ?? "";
|
|
54
|
+
const o = ones$1[num % 10];
|
|
55
|
+
return o ? `${t}-${o}` : t;
|
|
56
|
+
}
|
|
57
|
+
if (num < 1e3) {
|
|
58
|
+
const h = ones$1[Math.floor(num / 100)] ?? "";
|
|
59
|
+
const remainder = num % 100;
|
|
60
|
+
return remainder ? `${h} hundred ${convert$1(remainder)}` : `${h} hundred`;
|
|
61
|
+
}
|
|
62
|
+
if (num < 1e6) {
|
|
63
|
+
const th = convert$1(Math.floor(num / 1e3));
|
|
64
|
+
const remainder = num % 1e3;
|
|
65
|
+
return remainder ? `${th} thousand ${convert$1(remainder)}` : `${th} thousand`;
|
|
66
|
+
}
|
|
67
|
+
if (num < 1e9) {
|
|
68
|
+
const m = convert$1(Math.floor(num / 1e6));
|
|
69
|
+
const remainder = num % 1e6;
|
|
70
|
+
return remainder ? `${m} million ${convert$1(remainder)}` : `${m} million`;
|
|
71
|
+
}
|
|
72
|
+
const b = convert$1(Math.floor(num / 1e9));
|
|
73
|
+
const remainder = num % 1e9;
|
|
74
|
+
return remainder ? `${b} billion ${convert$1(remainder)}` : `${b} billion`;
|
|
75
|
+
}
|
|
76
|
+
const enSpellout = {
|
|
77
|
+
zeroWord: "zero",
|
|
78
|
+
pointWord: "point",
|
|
79
|
+
negativePrefix: "minus",
|
|
80
|
+
integerToWords(n) {
|
|
81
|
+
if (n === 0) return "zero";
|
|
82
|
+
return convert$1(n).trim();
|
|
83
|
+
},
|
|
84
|
+
digitToWord(digit) {
|
|
85
|
+
return digitWords$1[digit] ?? digit;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const enNumberShort = { thresholds: [
|
|
89
|
+
{
|
|
90
|
+
value: 0xe8d4a51000,
|
|
91
|
+
suffix: " Trillion"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
value: 1e9,
|
|
95
|
+
suffix: " Billion"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
value: 1e6,
|
|
99
|
+
suffix: " Million"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
value: 1e3,
|
|
103
|
+
suffix: "K"
|
|
104
|
+
}
|
|
105
|
+
] };
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/locales/vi.ts
|
|
108
|
+
const ones = [
|
|
109
|
+
"",
|
|
110
|
+
"một",
|
|
111
|
+
"hai",
|
|
112
|
+
"ba",
|
|
113
|
+
"bốn",
|
|
114
|
+
"năm",
|
|
115
|
+
"sáu",
|
|
116
|
+
"bảy",
|
|
117
|
+
"tám",
|
|
118
|
+
"chín"
|
|
119
|
+
];
|
|
120
|
+
const onesInTens = [
|
|
121
|
+
"",
|
|
122
|
+
"mốt",
|
|
123
|
+
"hai",
|
|
124
|
+
"ba",
|
|
125
|
+
"bốn",
|
|
126
|
+
"lăm",
|
|
127
|
+
"sáu",
|
|
128
|
+
"bảy",
|
|
129
|
+
"tám",
|
|
130
|
+
"chín"
|
|
131
|
+
];
|
|
132
|
+
const digitWords = {
|
|
133
|
+
"0": "không",
|
|
134
|
+
"1": "một",
|
|
135
|
+
"2": "hai",
|
|
136
|
+
"3": "ba",
|
|
137
|
+
"4": "bốn",
|
|
138
|
+
"5": "năm",
|
|
139
|
+
"6": "sáu",
|
|
140
|
+
"7": "bảy",
|
|
141
|
+
"8": "tám",
|
|
142
|
+
"9": "chín"
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Vietnamese number spellout following standard rules:
|
|
146
|
+
* - 5 in ones position of tens => "lam" (not "nam")
|
|
147
|
+
* - 1 in ones position of tens (>=20) => "mot" with special handling
|
|
148
|
+
* - 0 in ones position of tens => "muoi" only (no trailing)
|
|
149
|
+
* - Tens starting with 1 => "muoi", otherwise => "muoi" with prefix
|
|
150
|
+
*/
|
|
151
|
+
function readTens(t, u) {
|
|
152
|
+
let result = "";
|
|
153
|
+
if (t === 1) result = "mười";
|
|
154
|
+
else result = `${ones[t]} m\u01b0\u01a1i`;
|
|
155
|
+
if (u === 0) return result;
|
|
156
|
+
if (u === 1 && t > 1) return `${result} m\u1ed1t`;
|
|
157
|
+
if (u === 5 && t > 0) return `${result} l\u0103m`;
|
|
158
|
+
return `${result} ${onesInTens[u] ?? ""}`;
|
|
159
|
+
}
|
|
160
|
+
function readHundreds(h, t, u) {
|
|
161
|
+
const result = `${ones[h]} tr\u0103m`;
|
|
162
|
+
if (t === 0 && u === 0) return result;
|
|
163
|
+
if (t === 0) return `${result} linh ${ones[u]}`;
|
|
164
|
+
return `${result} ${readTens(t, u)}`;
|
|
165
|
+
}
|
|
166
|
+
function readBlock(num) {
|
|
167
|
+
if (num === 0) return "";
|
|
168
|
+
const h = Math.floor(num / 100);
|
|
169
|
+
const t = Math.floor(num % 100 / 10);
|
|
170
|
+
const u = num % 10;
|
|
171
|
+
if (h > 0) return readHundreds(h, t, u);
|
|
172
|
+
if (t > 0) return readTens(t, u);
|
|
173
|
+
return ones[u] ?? "";
|
|
174
|
+
}
|
|
175
|
+
function convert(num) {
|
|
176
|
+
if (num === 0) return "không";
|
|
177
|
+
const units = [
|
|
178
|
+
{
|
|
179
|
+
value: 1e9,
|
|
180
|
+
label: "tỷ"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
value: 1e6,
|
|
184
|
+
label: "triệu"
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
value: 1e3,
|
|
188
|
+
label: "nghìn"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
value: 1,
|
|
192
|
+
label: ""
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
const parts = [];
|
|
196
|
+
let remaining = num;
|
|
197
|
+
for (const unit of units) if (remaining >= unit.value) {
|
|
198
|
+
const block = Math.floor(remaining / unit.value);
|
|
199
|
+
remaining %= unit.value;
|
|
200
|
+
const blockStr = readBlock(block);
|
|
201
|
+
if (blockStr) parts.push(unit.label ? `${blockStr} ${unit.label}` : blockStr);
|
|
202
|
+
if (remaining > 0 && remaining < unit.value / 10) {
|
|
203
|
+
if (remaining < 100 && unit.value >= 1e3) {
|
|
204
|
+
parts.push("không trăm");
|
|
205
|
+
if (remaining < 10) {
|
|
206
|
+
parts.push(`linh ${ones[remaining]}`);
|
|
207
|
+
remaining = 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return parts.join(" ").trim();
|
|
213
|
+
}
|
|
214
|
+
const viSpellout = {
|
|
215
|
+
zeroWord: "không",
|
|
216
|
+
pointWord: "phẩy",
|
|
217
|
+
negativePrefix: "âm",
|
|
218
|
+
integerToWords(n) {
|
|
219
|
+
if (n === 0) return "không";
|
|
220
|
+
return convert(n);
|
|
221
|
+
},
|
|
222
|
+
digitToWord(digit) {
|
|
223
|
+
return digitWords[digit] ?? digit;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const viNumberShort = { thresholds: [
|
|
227
|
+
{
|
|
228
|
+
value: 0xe8d4a51000,
|
|
229
|
+
suffix: " Nghìn Tỷ"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
value: 1e9,
|
|
233
|
+
suffix: " Tỷ"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
value: 1e6,
|
|
237
|
+
suffix: " Triệu"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
value: 1e3,
|
|
241
|
+
suffix: " Ngàn"
|
|
242
|
+
}
|
|
243
|
+
] };
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/locales/index.ts
|
|
246
|
+
/** Built-in spellout locale registry. */
|
|
247
|
+
const spelloutRegistry = {
|
|
248
|
+
en: enSpellout,
|
|
249
|
+
vi: viSpellout
|
|
250
|
+
};
|
|
251
|
+
/** Built-in number-short locale registry. */
|
|
252
|
+
const numberShortRegistry = {
|
|
253
|
+
en: enNumberShort,
|
|
254
|
+
vi: viNumberShort
|
|
255
|
+
};
|
|
256
|
+
/** Get the spellout provider for a locale, falling back to English. */
|
|
257
|
+
function getSpellout(locale) {
|
|
258
|
+
const lang = locale.split("-")[0];
|
|
259
|
+
return spelloutRegistry[lang] ?? enSpellout;
|
|
260
|
+
}
|
|
261
|
+
/** Get the number-short config for a locale, falling back to English. */
|
|
262
|
+
function getNumberShortConfig(locale) {
|
|
263
|
+
const lang = locale.split("-")[0];
|
|
264
|
+
return numberShortRegistry[lang] ?? enNumberShort;
|
|
265
|
+
}
|
|
266
|
+
/** Register a custom spellout locale at runtime. */
|
|
267
|
+
function registerSpellout(lang, impl) {
|
|
268
|
+
spelloutRegistry[lang] = impl;
|
|
269
|
+
}
|
|
270
|
+
/** Register a custom number-short config at runtime. */
|
|
271
|
+
function registerNumberShort(lang, config) {
|
|
272
|
+
numberShortRegistry[lang] = config;
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/utils.ts
|
|
276
|
+
/**
|
|
277
|
+
* Escape the 5 HTML-special characters, equivalent to PHP's htmlspecialchars().
|
|
278
|
+
* No external dependency - pure string replacement.
|
|
279
|
+
*/
|
|
280
|
+
function escapeHtml(value) {
|
|
281
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Normalize an input value into a Date object.
|
|
285
|
+
* Accepts: Date, number (UNIX seconds or milliseconds), string (ISO 8601).
|
|
286
|
+
*/
|
|
287
|
+
function normalizeDate(value) {
|
|
288
|
+
if (value instanceof Date) return value;
|
|
289
|
+
if (typeof value === "number") return new Date(value < 0xe8d4a51000 ? value * 1e3 : value);
|
|
290
|
+
if (typeof value === "string") {
|
|
291
|
+
const parsed = new Date(value);
|
|
292
|
+
if (Number.isNaN(parsed.getTime())) throw new Error(`Cannot parse date value: "${value}"`);
|
|
293
|
+
return parsed;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`Invalid data type for date: ${typeof value}`);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Normalize an input value into a number.
|
|
299
|
+
* Accepts: number, numeric string (with optional comma grouping), boolean.
|
|
300
|
+
*/
|
|
301
|
+
function normalizeNumber(value) {
|
|
302
|
+
if (typeof value === "number") return value;
|
|
303
|
+
if (typeof value === "string") {
|
|
304
|
+
const cleaned = value.trim().replace(/,/g, "");
|
|
305
|
+
const num = Number(cleaned);
|
|
306
|
+
if (Number.isNaN(num)) throw new Error(`Cannot parse numeric value: "${value}"`);
|
|
307
|
+
return num;
|
|
308
|
+
}
|
|
309
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
310
|
+
throw new Error(`Invalid data type for number: ${typeof value}`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Convert a preset name (short/medium/long/full) to Intl.DateTimeFormatOptions.
|
|
314
|
+
*/
|
|
315
|
+
function presetToDateOptions(preset, type) {
|
|
316
|
+
const dateOptions = {
|
|
317
|
+
short: {
|
|
318
|
+
year: "2-digit",
|
|
319
|
+
month: "numeric",
|
|
320
|
+
day: "numeric"
|
|
321
|
+
},
|
|
322
|
+
medium: {
|
|
323
|
+
year: "numeric",
|
|
324
|
+
month: "short",
|
|
325
|
+
day: "numeric"
|
|
326
|
+
},
|
|
327
|
+
long: {
|
|
328
|
+
year: "numeric",
|
|
329
|
+
month: "long",
|
|
330
|
+
day: "numeric"
|
|
331
|
+
},
|
|
332
|
+
full: {
|
|
333
|
+
year: "numeric",
|
|
334
|
+
month: "long",
|
|
335
|
+
day: "numeric",
|
|
336
|
+
weekday: "long"
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const timeOptions = {
|
|
340
|
+
short: {
|
|
341
|
+
hour: "numeric",
|
|
342
|
+
minute: "numeric"
|
|
343
|
+
},
|
|
344
|
+
medium: {
|
|
345
|
+
hour: "numeric",
|
|
346
|
+
minute: "numeric",
|
|
347
|
+
second: "numeric"
|
|
348
|
+
},
|
|
349
|
+
long: {
|
|
350
|
+
hour: "numeric",
|
|
351
|
+
minute: "numeric",
|
|
352
|
+
second: "numeric",
|
|
353
|
+
timeZoneName: "short"
|
|
354
|
+
},
|
|
355
|
+
full: {
|
|
356
|
+
hour: "numeric",
|
|
357
|
+
minute: "numeric",
|
|
358
|
+
second: "numeric",
|
|
359
|
+
timeZoneName: "long"
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
switch (type) {
|
|
363
|
+
case "date": return dateOptions[preset] ?? dateOptions.medium;
|
|
364
|
+
case "time": return timeOptions[preset] ?? timeOptions.medium;
|
|
365
|
+
case "datetime": return {
|
|
366
|
+
...dateOptions[preset] ?? dateOptions.medium,
|
|
367
|
+
...timeOptions[preset] ?? timeOptions.medium
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Resolve a format value: string preset -> Intl options, object -> use directly.
|
|
373
|
+
*/
|
|
374
|
+
function resolveDateFormat(format, defaultPreset, type) {
|
|
375
|
+
if (!format) return presetToDateOptions(defaultPreset, type);
|
|
376
|
+
if (typeof format === "object") return format;
|
|
377
|
+
return presetToDateOptions(format, type);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Replace locale-default separators with custom ones in a formatted string.
|
|
381
|
+
* Uses temporary placeholders to avoid replacement collisions.
|
|
382
|
+
*/
|
|
383
|
+
function applyCustomSeparators(formatted, locale, customDecimal, customThousand) {
|
|
384
|
+
if (customDecimal == null && customThousand == null) return formatted;
|
|
385
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89);
|
|
386
|
+
const localeDecimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
|
|
387
|
+
const localeGroup = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
388
|
+
let result = formatted;
|
|
389
|
+
const PLACEHOLDER_DEC = "";
|
|
390
|
+
const PLACEHOLDER_GRP = "";
|
|
391
|
+
if (customDecimal != null) result = result.replaceAll(localeDecimal, PLACEHOLDER_DEC);
|
|
392
|
+
if (customThousand != null) result = result.replaceAll(localeGroup, PLACEHOLDER_GRP);
|
|
393
|
+
if (customDecimal != null) result = result.replaceAll(PLACEHOLDER_DEC, customDecimal);
|
|
394
|
+
if (customThousand != null) result = result.replaceAll(PLACEHOLDER_GRP, customThousand);
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/formatter.ts
|
|
399
|
+
/**
|
|
400
|
+
* TypeScript port of yii\i18n\Formatter.
|
|
401
|
+
*
|
|
402
|
+
* Uses only built-in Intl APIs - zero external dependencies.
|
|
403
|
+
* Supports: strings, HTML, numbers, currency, dates, times,
|
|
404
|
+
* file sizes, measurement units, and more.
|
|
405
|
+
*/
|
|
406
|
+
var Formatter = class Formatter {
|
|
407
|
+
locale;
|
|
408
|
+
timeZone;
|
|
409
|
+
defaultTimeZone;
|
|
410
|
+
dateFormat;
|
|
411
|
+
timeFormat;
|
|
412
|
+
datetimeFormat;
|
|
413
|
+
booleanFormat;
|
|
414
|
+
nullDisplay;
|
|
415
|
+
currencyCode;
|
|
416
|
+
decimalSeparator;
|
|
417
|
+
thousandSeparator;
|
|
418
|
+
currencyDecimalSeparator;
|
|
419
|
+
sizeFormatBase;
|
|
420
|
+
systemOfUnits;
|
|
421
|
+
defaultDecimalDigits;
|
|
422
|
+
constructor(options = {}) {
|
|
423
|
+
this.locale = options.locale ?? "en-US";
|
|
424
|
+
this.timeZone = options.timeZone ?? "UTC";
|
|
425
|
+
this.defaultTimeZone = options.defaultTimeZone ?? "UTC";
|
|
426
|
+
this.dateFormat = options.dateFormat ?? "medium";
|
|
427
|
+
this.timeFormat = options.timeFormat ?? "medium";
|
|
428
|
+
this.datetimeFormat = options.datetimeFormat ?? "medium";
|
|
429
|
+
this.booleanFormat = options.booleanFormat ?? ["No", "Yes"];
|
|
430
|
+
this.nullDisplay = options.nullDisplay ?? "(not set)";
|
|
431
|
+
this.currencyCode = options.currencyCode ?? "USD";
|
|
432
|
+
this.decimalSeparator = options.decimalSeparator ?? null;
|
|
433
|
+
this.thousandSeparator = options.thousandSeparator ?? null;
|
|
434
|
+
this.currencyDecimalSeparator = options.currencyDecimalSeparator ?? null;
|
|
435
|
+
this.sizeFormatBase = options.sizeFormatBase ?? 1024;
|
|
436
|
+
this.systemOfUnits = options.systemOfUnits ?? "metric";
|
|
437
|
+
this.defaultDecimalDigits = options.defaultDecimalDigits ?? null;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Format a value by type name, like Yii2's `$formatter->format($value, 'date')`.
|
|
441
|
+
* Supports both string and tuple `[formatName, ...params]` signatures.
|
|
442
|
+
*/
|
|
443
|
+
format(value, type) {
|
|
444
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
445
|
+
const formatName = Array.isArray(type) ? type[0] : type;
|
|
446
|
+
const params = Array.isArray(type) ? type.slice(1) : [];
|
|
447
|
+
const methodName = `as${formatName.charAt(0).toUpperCase()}${formatName.slice(1)}`;
|
|
448
|
+
const method = this[methodName];
|
|
449
|
+
if (typeof method === "function") return method.call(this, value, ...params);
|
|
450
|
+
throw new Error(`Unknown format type: ${formatName}`);
|
|
451
|
+
}
|
|
452
|
+
/** Returns the value as-is without any formatting. */
|
|
453
|
+
asRaw(value) {
|
|
454
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
455
|
+
return String(value);
|
|
456
|
+
}
|
|
457
|
+
/** Formats the value as HTML-encoded plain text. */
|
|
458
|
+
asText(value) {
|
|
459
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
460
|
+
return escapeHtml(String(value));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Formats the value as HTML-encoded text with newlines converted to `<br />`.
|
|
464
|
+
* Handles all line-ending variants: `\r\n` (Windows), `\r` (old Mac), `\n` (Unix).
|
|
465
|
+
* Consecutive newlines produce multiple `<br />` tags.
|
|
466
|
+
*/
|
|
467
|
+
asNtext(value) {
|
|
468
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
469
|
+
return escapeHtml(String(value)).replace(/\r\n/g, "<br />").replace(/[\r\n]/g, "<br />");
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Formats the value as HTML-encoded text paragraphs (split by double newlines).
|
|
473
|
+
* Supports configurable wrapper tag and inline line-break conversion.
|
|
474
|
+
*/
|
|
475
|
+
asParagraphs(value, options) {
|
|
476
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
477
|
+
const tag = options?.tag ?? "p";
|
|
478
|
+
const lineBreaks = options?.lineBreaks ?? false;
|
|
479
|
+
return String(value).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split(/\n\s*\n/).map((p) => {
|
|
480
|
+
let content = escapeHtml(p.trim());
|
|
481
|
+
if (lineBreaks) content = content.replace(/\n/g, "<br />");
|
|
482
|
+
return `<${tag}>${content}</${tag}>`;
|
|
483
|
+
}).filter((p) => p !== `<${tag}></${tag}>`).join("\n");
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Returns the value as HTML text.
|
|
487
|
+
* When a sanitize config is provided, only allowed tags and attributes are kept.
|
|
488
|
+
* Without config, the value is returned as-is (caller is responsible for safety).
|
|
489
|
+
*/
|
|
490
|
+
asHtml(value, sanitize) {
|
|
491
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
492
|
+
const html = String(value);
|
|
493
|
+
if (!sanitize) return html;
|
|
494
|
+
return Formatter.sanitizeHtml(html, sanitize);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Allowlist-based HTML sanitizer. Strips tags and attributes not in the config.
|
|
498
|
+
* Handles self-closing tags, nested tags, and attribute filtering.
|
|
499
|
+
*/
|
|
500
|
+
static sanitizeHtml(html, config) {
|
|
501
|
+
const allowedTags = new Set((config.allowedTags ?? []).map((t) => t.toLowerCase()));
|
|
502
|
+
const allowedAttrs = config.allowedAttributes ?? {};
|
|
503
|
+
return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*?)\s*\/?>/g, (match, tagName, attrsStr) => {
|
|
504
|
+
const tag = tagName.toLowerCase();
|
|
505
|
+
if (!allowedTags.has(tag)) return "";
|
|
506
|
+
if (match.startsWith("</")) return `</${tag}>`;
|
|
507
|
+
const isSelfClosing = match.endsWith("/>");
|
|
508
|
+
const tagAllowedAttrs = new Set((allowedAttrs[tag] ?? []).map((a) => a.toLowerCase()));
|
|
509
|
+
const filteredAttrs = [];
|
|
510
|
+
const attrRegex = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
511
|
+
let attrMatch = null;
|
|
512
|
+
while (true) {
|
|
513
|
+
attrMatch = attrRegex.exec(attrsStr);
|
|
514
|
+
if (!attrMatch) break;
|
|
515
|
+
const attrName = attrMatch[1].toLowerCase();
|
|
516
|
+
if (tagAllowedAttrs.has(attrName)) {
|
|
517
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
518
|
+
if (attrValue !== void 0) filteredAttrs.push(`${attrName}="${escapeHtml(attrValue)}"`);
|
|
519
|
+
else filteredAttrs.push(attrName);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const attrsOut = filteredAttrs.length > 0 ? ` ${filteredAttrs.join(" ")}` : "";
|
|
523
|
+
return isSelfClosing ? `<${tag}${attrsOut} />` : `<${tag}${attrsOut}>`;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Formats the value as a mailto link.
|
|
528
|
+
* Supports custom display text, subject, and body parameters.
|
|
529
|
+
* Validates email format - returns escaped plain text for invalid emails.
|
|
530
|
+
*/
|
|
531
|
+
asEmail(value, options) {
|
|
532
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
533
|
+
const email = String(value);
|
|
534
|
+
if (!Formatter.isValidEmail(email)) return escapeHtml(email);
|
|
535
|
+
const params = [];
|
|
536
|
+
if (options?.subject) params.push(`subject=${encodeURIComponent(options.subject)}`);
|
|
537
|
+
if (options?.body) params.push(`body=${encodeURIComponent(options.body)}`);
|
|
538
|
+
const query = params.length > 0 ? `?${params.join("&")}` : "";
|
|
539
|
+
const displayText = escapeHtml(options?.text ?? email);
|
|
540
|
+
return `<a href="mailto:${escapeHtml(email)}${query}">${displayText}</a>`;
|
|
541
|
+
}
|
|
542
|
+
/** Basic email format validation (covers most common patterns). */
|
|
543
|
+
static isValidEmail(email) {
|
|
544
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Formats the value as a hyperlink.
|
|
548
|
+
* Detects http, https, ftp, ftps, and mailto schemes.
|
|
549
|
+
* Prepends `http://` when no recognized scheme is present.
|
|
550
|
+
*/
|
|
551
|
+
asUrl(value, options) {
|
|
552
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
553
|
+
const url = String(value);
|
|
554
|
+
const href = /^(https?|ftps?|mailto):/i.test(url) ? url : `http://${url}`;
|
|
555
|
+
const target = options?.target ?? "_blank";
|
|
556
|
+
const displayText = escapeHtml(options?.text ?? url);
|
|
557
|
+
const attrs = [`href="${escapeHtml(href)}"`, `target="${escapeHtml(target)}"`];
|
|
558
|
+
if (options?.rel) attrs.push(`rel="${escapeHtml(options.rel)}"`);
|
|
559
|
+
if (options?.class) attrs.push(`class="${escapeHtml(options.class)}"`);
|
|
560
|
+
return `<a ${attrs.join(" ")}>${displayText}</a>`;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Formats the value as an image tag.
|
|
564
|
+
* Supports width, height, CSS class, and loading strategy attributes.
|
|
565
|
+
*/
|
|
566
|
+
asImage(value, options) {
|
|
567
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
568
|
+
const src = String(value);
|
|
569
|
+
const alt = options?.alt ?? "";
|
|
570
|
+
const attrs = [`src="${escapeHtml(src)}"`, `alt="${escapeHtml(alt)}"`];
|
|
571
|
+
if (options?.width != null) attrs.push(`width="${escapeHtml(String(options.width))}"`);
|
|
572
|
+
if (options?.height != null) attrs.push(`height="${escapeHtml(String(options.height))}"`);
|
|
573
|
+
if (options?.class) attrs.push(`class="${escapeHtml(options.class)}"`);
|
|
574
|
+
if (options?.loading) attrs.push(`loading="${escapeHtml(options.loading)}"`);
|
|
575
|
+
return `<img ${attrs.join(" ")} />`;
|
|
576
|
+
}
|
|
577
|
+
/** Formats the value as a boolean using the configured booleanFormat labels. */
|
|
578
|
+
asBoolean(value) {
|
|
579
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
580
|
+
return value ? this.booleanFormat[1] : this.booleanFormat[0];
|
|
581
|
+
}
|
|
582
|
+
/** Formats the value as an integer by removing decimal digits without rounding. */
|
|
583
|
+
asInteger(value) {
|
|
584
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
585
|
+
const num = normalizeNumber(value);
|
|
586
|
+
const intVal = Math.trunc(num);
|
|
587
|
+
return applyCustomSeparators(new Intl.NumberFormat(this.locale, {
|
|
588
|
+
maximumFractionDigits: 0,
|
|
589
|
+
minimumFractionDigits: 0
|
|
590
|
+
}).format(intVal), this.locale, this.decimalSeparator, this.thousandSeparator);
|
|
591
|
+
}
|
|
592
|
+
/** Formats the value as a decimal number. */
|
|
593
|
+
asDecimal(value, decimals) {
|
|
594
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
595
|
+
const num = normalizeNumber(value);
|
|
596
|
+
const digits = decimals ?? this.defaultDecimalDigits ?? 2;
|
|
597
|
+
return applyCustomSeparators(new Intl.NumberFormat(this.locale, {
|
|
598
|
+
minimumFractionDigits: digits,
|
|
599
|
+
maximumFractionDigits: digits
|
|
600
|
+
}).format(num), this.locale, this.decimalSeparator, this.thousandSeparator);
|
|
601
|
+
}
|
|
602
|
+
/** Formats the value as a percent number with "%" sign. */
|
|
603
|
+
asPercent(value, decimals) {
|
|
604
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
605
|
+
const num = normalizeNumber(value);
|
|
606
|
+
const digits = decimals ?? this.defaultDecimalDigits ?? 0;
|
|
607
|
+
return applyCustomSeparators(new Intl.NumberFormat(this.locale, {
|
|
608
|
+
style: "percent",
|
|
609
|
+
minimumFractionDigits: digits,
|
|
610
|
+
maximumFractionDigits: digits
|
|
611
|
+
}).format(num), this.locale, this.decimalSeparator, this.thousandSeparator);
|
|
612
|
+
}
|
|
613
|
+
/** Formats the value as a currency number using ISO 4217 codes. */
|
|
614
|
+
asCurrency(value, currency) {
|
|
615
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
616
|
+
const num = normalizeNumber(value);
|
|
617
|
+
const code = currency ?? this.currencyCode;
|
|
618
|
+
return applyCustomSeparators(new Intl.NumberFormat(this.locale, {
|
|
619
|
+
style: "currency",
|
|
620
|
+
currency: code
|
|
621
|
+
}).format(num), this.locale, this.currencyDecimalSeparator ?? this.decimalSeparator, this.thousandSeparator);
|
|
622
|
+
}
|
|
623
|
+
/** Formats the value as a scientific number (e-notation). */
|
|
624
|
+
asScientific(value, decimals) {
|
|
625
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
626
|
+
const num = normalizeNumber(value);
|
|
627
|
+
const digits = decimals ?? this.defaultDecimalDigits ?? 2;
|
|
628
|
+
return new Intl.NumberFormat(this.locale, {
|
|
629
|
+
notation: "scientific",
|
|
630
|
+
minimumFractionDigits: digits,
|
|
631
|
+
maximumFractionDigits: digits
|
|
632
|
+
}).format(num);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Formats the value as a number spellout (e.g. 42 -> "forty-two").
|
|
636
|
+
* Supports multiple locales via the locales/ registry.
|
|
637
|
+
*/
|
|
638
|
+
asSpellout(value) {
|
|
639
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
640
|
+
const num = normalizeNumber(value);
|
|
641
|
+
const spellout = getSpellout(this.locale);
|
|
642
|
+
if (num === 0) return spellout.zeroWord;
|
|
643
|
+
const isNegative = num < 0;
|
|
644
|
+
const absNum = Math.abs(num);
|
|
645
|
+
const intPart = Math.trunc(absNum);
|
|
646
|
+
const decPart = absNum - intPart;
|
|
647
|
+
let result = spellout.integerToWords(intPart);
|
|
648
|
+
if (decPart > 0) {
|
|
649
|
+
const decDigits = (String(absNum).split(".")[1] ?? "").split("").map((d) => spellout.digitToWord(d));
|
|
650
|
+
result += ` ${spellout.pointWord} ${decDigits.join(" ")}`;
|
|
651
|
+
}
|
|
652
|
+
return isNegative ? `${spellout.negativePrefix} ${result}` : result;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Formats the value as an ordinal number (e.g. 1 -> "1st", 2 -> "2nd").
|
|
656
|
+
* Supports multiple locales via built-in suffix maps and custom overrides
|
|
657
|
+
* through `Formatter.registerOrdinalSuffixes()`.
|
|
658
|
+
*/
|
|
659
|
+
asOrdinal(value) {
|
|
660
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
661
|
+
const num = Math.trunc(normalizeNumber(value));
|
|
662
|
+
try {
|
|
663
|
+
const rule = new Intl.PluralRules(this.locale, { type: "ordinal" }).select(num);
|
|
664
|
+
const lang = this.locale.split("-")[0];
|
|
665
|
+
const enSuffixes = Formatter.ordinalSuffixes.en ?? { other: "th" };
|
|
666
|
+
const langSuffixes = Formatter.ordinalSuffixes[lang] ?? enSuffixes;
|
|
667
|
+
const suffix = langSuffixes[rule] ?? langSuffixes.other ?? "";
|
|
668
|
+
return `${new Intl.NumberFormat(this.locale).format(num)}${suffix}`;
|
|
669
|
+
} catch {
|
|
670
|
+
return `${num}${this.getOrdinalSuffixEn(num)}`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** Built-in ordinal suffix registry. Extensible at runtime. */
|
|
674
|
+
static ordinalSuffixes = {
|
|
675
|
+
en: {
|
|
676
|
+
one: "st",
|
|
677
|
+
two: "nd",
|
|
678
|
+
few: "rd",
|
|
679
|
+
other: "th"
|
|
680
|
+
},
|
|
681
|
+
vi: { other: "" },
|
|
682
|
+
fr: {
|
|
683
|
+
one: "er",
|
|
684
|
+
other: "e"
|
|
685
|
+
},
|
|
686
|
+
de: { other: "." },
|
|
687
|
+
es: { other: "." },
|
|
688
|
+
pt: { other: "." },
|
|
689
|
+
it: { other: "." },
|
|
690
|
+
ja: { other: "" },
|
|
691
|
+
ko: { other: "" },
|
|
692
|
+
zh: { other: "" }
|
|
693
|
+
};
|
|
694
|
+
/**
|
|
695
|
+
* Register ordinal suffixes for a language at runtime.
|
|
696
|
+
* Keys are Intl.PluralRules ordinal categories: "one", "two", "few", "other".
|
|
697
|
+
*/
|
|
698
|
+
static registerOrdinalSuffixes(lang, suffixes) {
|
|
699
|
+
Formatter.ordinalSuffixes[lang] = suffixes;
|
|
700
|
+
}
|
|
701
|
+
/** Formats the value as a date. */
|
|
702
|
+
asDate(value, format) {
|
|
703
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
704
|
+
const date = normalizeDate(value);
|
|
705
|
+
const resolved = resolveDateFormat(format ?? this.dateFormat, "medium", "date");
|
|
706
|
+
return new Intl.DateTimeFormat(this.locale, {
|
|
707
|
+
...resolved,
|
|
708
|
+
timeZone: this.timeZone
|
|
709
|
+
}).format(date);
|
|
710
|
+
}
|
|
711
|
+
/** Formats the value as a time. */
|
|
712
|
+
asTime(value, format) {
|
|
713
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
714
|
+
const date = normalizeDate(value);
|
|
715
|
+
const resolved = resolveDateFormat(format ?? this.timeFormat, "medium", "time");
|
|
716
|
+
return new Intl.DateTimeFormat(this.locale, {
|
|
717
|
+
...resolved,
|
|
718
|
+
timeZone: this.timeZone
|
|
719
|
+
}).format(date);
|
|
720
|
+
}
|
|
721
|
+
/** Formats the value as a datetime. */
|
|
722
|
+
asDatetime(value, format) {
|
|
723
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
724
|
+
const date = normalizeDate(value);
|
|
725
|
+
const resolved = resolveDateFormat(format ?? this.datetimeFormat, "medium", "datetime");
|
|
726
|
+
return new Intl.DateTimeFormat(this.locale, {
|
|
727
|
+
...resolved,
|
|
728
|
+
timeZone: this.timeZone
|
|
729
|
+
}).format(date);
|
|
730
|
+
}
|
|
731
|
+
/** Returns the value as a UNIX timestamp (seconds since epoch). */
|
|
732
|
+
asTimestamp(value) {
|
|
733
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
734
|
+
const date = normalizeDate(value);
|
|
735
|
+
return String(Math.floor(date.getTime() / 1e3));
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Formats the value as the time interval between a date and now in human readable form.
|
|
739
|
+
* Uses Intl.RelativeTimeFormat (built-in in Node.js / browsers).
|
|
740
|
+
*/
|
|
741
|
+
asRelativeTime(value, referenceTime) {
|
|
742
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
743
|
+
const date = normalizeDate(value);
|
|
744
|
+
const ref = referenceTime ? normalizeDate(referenceTime) : /* @__PURE__ */ new Date();
|
|
745
|
+
const diffMs = date.getTime() - ref.getTime();
|
|
746
|
+
const diffSec = Math.round(diffMs / 1e3);
|
|
747
|
+
const rtf = new Intl.RelativeTimeFormat(this.locale, { numeric: "auto" });
|
|
748
|
+
const absSec = Math.abs(diffSec);
|
|
749
|
+
if (absSec < 60) return rtf.format(diffSec, "second");
|
|
750
|
+
if (absSec < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
|
|
751
|
+
if (absSec < 86400) return rtf.format(Math.round(diffSec / 3600), "hour");
|
|
752
|
+
if (absSec < 2592e3) return rtf.format(Math.round(diffSec / 86400), "day");
|
|
753
|
+
if (absSec < 31536e3) return rtf.format(Math.round(diffSec / 2592e3), "month");
|
|
754
|
+
return rtf.format(Math.round(diffSec / 31536e3), "year");
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Represents the value as duration in human readable format.
|
|
758
|
+
* Example: 5400 -> "1 hour, 30 minutes"
|
|
759
|
+
*/
|
|
760
|
+
asDuration(value, implode) {
|
|
761
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
762
|
+
let seconds = Math.abs(normalizeNumber(value));
|
|
763
|
+
const separator = implode ?? ", ";
|
|
764
|
+
if (seconds === 0) return this.getDurationLabel("second", 0);
|
|
765
|
+
const units = [
|
|
766
|
+
{
|
|
767
|
+
unit: "year",
|
|
768
|
+
divisor: 31536e3
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
unit: "month",
|
|
772
|
+
divisor: 2592e3
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
unit: "day",
|
|
776
|
+
divisor: 86400
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
unit: "hour",
|
|
780
|
+
divisor: 3600
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
unit: "minute",
|
|
784
|
+
divisor: 60
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
unit: "second",
|
|
788
|
+
divisor: 1
|
|
789
|
+
}
|
|
790
|
+
];
|
|
791
|
+
const parts = [];
|
|
792
|
+
for (const { unit, divisor } of units) if (seconds >= divisor) {
|
|
793
|
+
const count = Math.floor(seconds / divisor);
|
|
794
|
+
seconds %= divisor;
|
|
795
|
+
parts.push(this.getDurationLabel(unit, count));
|
|
796
|
+
}
|
|
797
|
+
return parts.join(separator);
|
|
798
|
+
}
|
|
799
|
+
/** Formats the value in bytes as a size in human readable form (e.g. "12 kilobytes"). */
|
|
800
|
+
asSize(value, decimals) {
|
|
801
|
+
return this.formatBytes(value, decimals, "long");
|
|
802
|
+
}
|
|
803
|
+
/** Formats the value in bytes as a size in human readable form (e.g. "12 kB"). */
|
|
804
|
+
asShortSize(value, decimals) {
|
|
805
|
+
return this.formatBytes(value, decimals, "short");
|
|
806
|
+
}
|
|
807
|
+
/** Formats the value as a length in human readable form (e.g. "12 meters"). */
|
|
808
|
+
asLength(value, decimals) {
|
|
809
|
+
return this.formatMeasure(value, "length", "long", decimals);
|
|
810
|
+
}
|
|
811
|
+
/** Formats the value as a length in human readable form (e.g. "12 m"). */
|
|
812
|
+
asShortLength(value, decimals) {
|
|
813
|
+
return this.formatMeasure(value, "length", "short", decimals);
|
|
814
|
+
}
|
|
815
|
+
/** Formats the value as a weight in human readable form (e.g. "12 kilograms"). */
|
|
816
|
+
asWeight(value, decimals) {
|
|
817
|
+
return this.formatMeasure(value, "mass", "long", decimals);
|
|
818
|
+
}
|
|
819
|
+
/** Formats the value as a weight in human readable form (e.g. "12 kg"). */
|
|
820
|
+
asShortWeight(value, decimals) {
|
|
821
|
+
return this.formatMeasure(value, "mass", "short", decimals);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Abbreviate a large number with locale-aware suffixes.
|
|
825
|
+
* e.g. 1500000 -> "1.5 Million" (en) or "1,5 Trieu" (vi).
|
|
826
|
+
*/
|
|
827
|
+
asNumberShort(value, options) {
|
|
828
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
829
|
+
const num = normalizeNumber(value);
|
|
830
|
+
const absNum = Math.abs(num);
|
|
831
|
+
const decimals = options?.decimals ?? 1;
|
|
832
|
+
const fallback = options?.fallback ?? "currency";
|
|
833
|
+
const addSpace = options?.spaceBefore ?? false;
|
|
834
|
+
const config = getNumberShortConfig(this.locale);
|
|
835
|
+
for (const { value: threshold, suffix } of config.thresholds) if (absNum >= threshold) {
|
|
836
|
+
const short = Math.round(num / threshold * 10 ** decimals) / 10 ** decimals;
|
|
837
|
+
const sep = addSpace && !suffix.startsWith(" ") ? " " : "";
|
|
838
|
+
return `${this.asDecimal(short, decimals)}${sep}${suffix}`;
|
|
839
|
+
}
|
|
840
|
+
switch (fallback) {
|
|
841
|
+
case "decimal": return this.asDecimal(num, decimals);
|
|
842
|
+
case "integer": return this.asInteger(num);
|
|
843
|
+
default: return this.asCurrency(num);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Format the GPS (great-circle) distance between two coordinates.
|
|
848
|
+
* Returns a human-readable string with unit suffix.
|
|
849
|
+
*/
|
|
850
|
+
asGpsDistance(latFrom, lonFrom, latTo, lonTo, options) {
|
|
851
|
+
const earthRadius = options?.earthRadius ?? 6371e3;
|
|
852
|
+
const decimals = options?.decimals ?? 1;
|
|
853
|
+
const rawUnit = options?.unit ?? "auto";
|
|
854
|
+
const meters = Formatter.gpsDistance(latFrom, lonFrom, latTo, lonTo, earthRadius);
|
|
855
|
+
let value;
|
|
856
|
+
let unit;
|
|
857
|
+
if (rawUnit === "mi") {
|
|
858
|
+
value = meters / 1609.344;
|
|
859
|
+
unit = "mi";
|
|
860
|
+
} else if (rawUnit === "km") {
|
|
861
|
+
value = meters / 1e3;
|
|
862
|
+
unit = "km";
|
|
863
|
+
} else if (rawUnit === "m") {
|
|
864
|
+
value = meters;
|
|
865
|
+
unit = "m";
|
|
866
|
+
} else if (meters >= 1e3) {
|
|
867
|
+
value = meters / 1e3;
|
|
868
|
+
unit = "km";
|
|
869
|
+
} else {
|
|
870
|
+
value = meters;
|
|
871
|
+
unit = "m";
|
|
872
|
+
}
|
|
873
|
+
return `${this.asDecimal(value, decimals)} ${unit}`;
|
|
874
|
+
}
|
|
875
|
+
/** Haversine formula: returns distance in meters between two GPS coordinates. */
|
|
876
|
+
static gpsDistance(latitudeFrom, longitudeFrom, latitudeTo, longitudeTo, earthRadius = 6371e3) {
|
|
877
|
+
const toRad = (deg) => deg * Math.PI / 180;
|
|
878
|
+
const latFrom = toRad(latitudeFrom);
|
|
879
|
+
const latTo = toRad(latitudeTo);
|
|
880
|
+
const deltaLat = toRad(latitudeTo - latitudeFrom);
|
|
881
|
+
const deltaLon = toRad(longitudeTo - longitudeFrom);
|
|
882
|
+
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(latFrom) * Math.cos(latTo) * Math.sin(deltaLon / 2) ** 2;
|
|
883
|
+
return earthRadius * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Mask a string value, showing only the first and last N characters.
|
|
887
|
+
* Instance method with options support.
|
|
888
|
+
*/
|
|
889
|
+
asMaskedValue(value, options) {
|
|
890
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
891
|
+
const str = String(value);
|
|
892
|
+
return Formatter.getMaskedValue(str, options?.startVisible ?? 4, options?.endVisible ?? 3, options?.maskChar ?? "X");
|
|
893
|
+
}
|
|
894
|
+
/** Core masking logic used by asMaskedValue(). */
|
|
895
|
+
static getMaskedValue(value, startVisible = 4, endVisible = 3, maskChar = "X") {
|
|
896
|
+
if (!value) return "";
|
|
897
|
+
const len = value.length;
|
|
898
|
+
if (len <= startVisible + endVisible) return value;
|
|
899
|
+
const start = value.slice(0, startVisible);
|
|
900
|
+
const end = value.slice(len - endVisible);
|
|
901
|
+
return `${start}${maskChar.repeat(len - startVisible - endVisible)}${end}`;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Format bytes into the most appropriate size unit.
|
|
905
|
+
* Supports both base-1024 (binary) and base-1000 (decimal).
|
|
906
|
+
*/
|
|
907
|
+
formatBytes(value, decimals, width) {
|
|
908
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
909
|
+
let bytes = normalizeNumber(value);
|
|
910
|
+
const digits = decimals ?? this.defaultDecimalDigits ?? 2;
|
|
911
|
+
const base = this.sizeFormatBase;
|
|
912
|
+
const isNegative = bytes < 0;
|
|
913
|
+
bytes = Math.abs(bytes);
|
|
914
|
+
const units = base === 1024 ? [
|
|
915
|
+
{
|
|
916
|
+
threshold: 1099511627776,
|
|
917
|
+
long: "terabytes",
|
|
918
|
+
short: "TB"
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
threshold: 1073741824,
|
|
922
|
+
long: "gigabytes",
|
|
923
|
+
short: "GB"
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
threshold: 1048576,
|
|
927
|
+
long: "megabytes",
|
|
928
|
+
short: "MB"
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
threshold: 1024,
|
|
932
|
+
long: "kilobytes",
|
|
933
|
+
short: "KB"
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
threshold: 0,
|
|
937
|
+
long: "bytes",
|
|
938
|
+
short: "B"
|
|
939
|
+
}
|
|
940
|
+
] : [
|
|
941
|
+
{
|
|
942
|
+
threshold: 0xe8d4a51000,
|
|
943
|
+
long: "terabytes",
|
|
944
|
+
short: "TB"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
threshold: 1e9,
|
|
948
|
+
long: "gigabytes",
|
|
949
|
+
short: "GB"
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
threshold: 1e6,
|
|
953
|
+
long: "megabytes",
|
|
954
|
+
short: "MB"
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
threshold: 1e3,
|
|
958
|
+
long: "kilobytes",
|
|
959
|
+
short: "KB"
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
threshold: 0,
|
|
963
|
+
long: "bytes",
|
|
964
|
+
short: "B"
|
|
965
|
+
}
|
|
966
|
+
];
|
|
967
|
+
for (const unit of units) if (bytes >= unit.threshold && unit.threshold > 0) {
|
|
968
|
+
const val = bytes / unit.threshold;
|
|
969
|
+
return `${isNegative ? "-" : ""}${this.formatNumberPart(val, digits)} ${width === "long" ? unit.long : unit.short}`;
|
|
970
|
+
}
|
|
971
|
+
return `${isNegative ? "-" : ""}${Math.round(bytes)} ${width === "long" ? "bytes" : "B"}`;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Format a measurement value (length or mass).
|
|
975
|
+
* Automatically selects the most appropriate unit based on value magnitude.
|
|
976
|
+
*/
|
|
977
|
+
formatMeasure(value, type, width, decimals) {
|
|
978
|
+
if (value === null || value === void 0) return this.nullDisplay;
|
|
979
|
+
const num = normalizeNumber(value);
|
|
980
|
+
const digits = decimals ?? this.defaultDecimalDigits ?? 2;
|
|
981
|
+
const configs = this.getMeasureUnits(type);
|
|
982
|
+
const isNegative = num < 0;
|
|
983
|
+
const absNum = Math.abs(num);
|
|
984
|
+
for (let i = configs.length - 1; i >= 0; i--) {
|
|
985
|
+
const config = configs[i];
|
|
986
|
+
if (!config) continue;
|
|
987
|
+
if (absNum >= config.factor || i === 0) {
|
|
988
|
+
const val = absNum / config.factor;
|
|
989
|
+
return `${isNegative ? "-" : ""}${this.formatNumberPart(val, digits)} ${width === "long" ? config.longLabel : config.shortLabel}`;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return String(num);
|
|
993
|
+
}
|
|
994
|
+
/** Get measurement unit configs for the configured system (metric/imperial). */
|
|
995
|
+
getMeasureUnits(type) {
|
|
996
|
+
if (type === "length") {
|
|
997
|
+
if (this.systemOfUnits === "imperial") return [
|
|
998
|
+
{
|
|
999
|
+
factor: 1,
|
|
1000
|
+
longLabel: "inches",
|
|
1001
|
+
shortLabel: "in"
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
factor: 12,
|
|
1005
|
+
longLabel: "feet",
|
|
1006
|
+
shortLabel: "ft"
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
factor: 36,
|
|
1010
|
+
longLabel: "yards",
|
|
1011
|
+
shortLabel: "yd"
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
factor: 63360,
|
|
1015
|
+
longLabel: "miles",
|
|
1016
|
+
shortLabel: "mi"
|
|
1017
|
+
}
|
|
1018
|
+
];
|
|
1019
|
+
return [
|
|
1020
|
+
{
|
|
1021
|
+
factor: 1,
|
|
1022
|
+
longLabel: "millimeters",
|
|
1023
|
+
shortLabel: "mm"
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
factor: 1e3,
|
|
1027
|
+
longLabel: "meters",
|
|
1028
|
+
shortLabel: "m"
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
factor: 1e6,
|
|
1032
|
+
longLabel: "kilometers",
|
|
1033
|
+
shortLabel: "km"
|
|
1034
|
+
}
|
|
1035
|
+
];
|
|
1036
|
+
}
|
|
1037
|
+
if (this.systemOfUnits === "imperial") return [
|
|
1038
|
+
{
|
|
1039
|
+
factor: 1,
|
|
1040
|
+
longLabel: "grains",
|
|
1041
|
+
shortLabel: "gr"
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
factor: 437.5,
|
|
1045
|
+
longLabel: "ounces",
|
|
1046
|
+
shortLabel: "oz"
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
factor: 7e3,
|
|
1050
|
+
longLabel: "pounds",
|
|
1051
|
+
shortLabel: "lb"
|
|
1052
|
+
}
|
|
1053
|
+
];
|
|
1054
|
+
return [
|
|
1055
|
+
{
|
|
1056
|
+
factor: 1,
|
|
1057
|
+
longLabel: "grams",
|
|
1058
|
+
shortLabel: "g"
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
factor: 1e3,
|
|
1062
|
+
longLabel: "kilograms",
|
|
1063
|
+
shortLabel: "kg"
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
factor: 1e6,
|
|
1067
|
+
longLabel: "tons",
|
|
1068
|
+
shortLabel: "t"
|
|
1069
|
+
}
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
/** Format the numeric part of a result using locale-aware Intl. */
|
|
1073
|
+
formatNumberPart(num, digits) {
|
|
1074
|
+
return applyCustomSeparators(new Intl.NumberFormat(this.locale, {
|
|
1075
|
+
minimumFractionDigits: 0,
|
|
1076
|
+
maximumFractionDigits: digits
|
|
1077
|
+
}).format(num), this.locale, this.decimalSeparator, this.thousandSeparator);
|
|
1078
|
+
}
|
|
1079
|
+
/** Create a locale-aware duration label using Intl unit formatting. */
|
|
1080
|
+
getDurationLabel(unit, count) {
|
|
1081
|
+
try {
|
|
1082
|
+
const intlUnit = unit === "month" ? "month" : unit;
|
|
1083
|
+
return new Intl.NumberFormat(this.locale, {
|
|
1084
|
+
style: "unit",
|
|
1085
|
+
unit: intlUnit,
|
|
1086
|
+
unitDisplay: "long"
|
|
1087
|
+
}).format(count);
|
|
1088
|
+
} catch {
|
|
1089
|
+
return `${count} ${unit}${count !== 1 ? "s" : ""}`;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** English ordinal suffix fallback. */
|
|
1093
|
+
getOrdinalSuffixEn(n) {
|
|
1094
|
+
const abs = Math.abs(n);
|
|
1095
|
+
const mod100 = abs % 100;
|
|
1096
|
+
if (mod100 >= 11 && mod100 <= 13) return "th";
|
|
1097
|
+
switch (abs % 10) {
|
|
1098
|
+
case 1: return "st";
|
|
1099
|
+
case 2: return "nd";
|
|
1100
|
+
case 3: return "rd";
|
|
1101
|
+
default: return "th";
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/global.ts
|
|
1107
|
+
/**
|
|
1108
|
+
* Global singleton Formatter instance.
|
|
1109
|
+
*
|
|
1110
|
+
* Usage: import once at app entry, configure once, then use `formatter` everywhere.
|
|
1111
|
+
*
|
|
1112
|
+
* ```ts
|
|
1113
|
+
* // main.ts (once)
|
|
1114
|
+
* import { configureFormatter } from "@template/helpers"
|
|
1115
|
+
* configureFormatter({ locale: "vi-VN", currencyCode: "VND" })
|
|
1116
|
+
*
|
|
1117
|
+
* // any-page.tsx (no setup needed)
|
|
1118
|
+
* import { formatter } from "@template/helpers"
|
|
1119
|
+
* formatter.asCurrency(1234567)
|
|
1120
|
+
* ```
|
|
1121
|
+
*/
|
|
1122
|
+
let instance = new Formatter();
|
|
1123
|
+
/** The global Formatter singleton. Ready to use after `configureFormatter()`. */
|
|
1124
|
+
const formatter = new Proxy({}, { get(_target, prop, receiver) {
|
|
1125
|
+
return Reflect.get(instance, prop, receiver);
|
|
1126
|
+
} });
|
|
1127
|
+
/**
|
|
1128
|
+
* Configure the global formatter once (typically at app bootstrap).
|
|
1129
|
+
* Replaces the internal instance - all existing `formatter` references
|
|
1130
|
+
* automatically pick up the new config via the proxy.
|
|
1131
|
+
*/
|
|
1132
|
+
function configureFormatter(options) {
|
|
1133
|
+
instance = new Formatter(options);
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
exports.Formatter = Formatter;
|
|
1137
|
+
exports.configureFormatter = configureFormatter;
|
|
1138
|
+
exports.escapeHtml = escapeHtml;
|
|
1139
|
+
exports.formatter = formatter;
|
|
1140
|
+
exports.getNumberShortConfig = getNumberShortConfig;
|
|
1141
|
+
exports.getSpellout = getSpellout;
|
|
1142
|
+
exports.normalizeDate = normalizeDate;
|
|
1143
|
+
exports.normalizeNumber = normalizeNumber;
|
|
1144
|
+
exports.registerNumberShort = registerNumberShort;
|
|
1145
|
+
exports.registerSpellout = registerSpellout;
|
|
1146
|
+
|
|
1147
|
+
//# sourceMappingURL=index.cjs.map
|