@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wikytam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,574 @@
1
+ # @wikytam/helpers
2
+
3
+ A TypeScript utility library ported from [yii\i18n\Formatter](https://www.yiiframework.com/doc/api/2.0/yii-i18n-formatter) and common Yii2 helpers, with **zero external dependencies** - uses only built-in `Intl` APIs.
4
+
5
+ | | Size |
6
+ |---|---|
7
+ | ESM | 35.2 kB (9.5 kB gzip) |
8
+ | CJS | 35.5 kB (9.6 kB gzip) |
9
+ | Types | 16.0 kB |
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @wikytam/helpers
15
+ # or
16
+ pnpm add @wikytam/helpers
17
+ # or
18
+ yarn add @wikytam/helpers
19
+ ```
20
+
21
+ ```typescript
22
+ import { Formatter } from "@wikytam/helpers"
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import { Formatter } from "@wikytam/helpers"
29
+
30
+ const f = new Formatter({
31
+ locale: "en-US",
32
+ timeZone: "UTC",
33
+ currencyCode: "USD",
34
+ })
35
+
36
+ f.asDate("2024-03-15") // "Mar 15, 2024"
37
+ f.asCurrency(1234.56) // "$1,234.56"
38
+ f.asPercent(0.156, 1) // "15.6%"
39
+ f.asShortSize(1048576) // "1 MB"
40
+ f.asRelativeTime(pastDate) // "2 days ago"
41
+ f.asBoolean(true) // "Yes"
42
+ f.asSpellout(42) // "forty-two"
43
+ f.asOrdinal(3) // "3rd"
44
+ ```
45
+
46
+
47
+
48
+ ## Global Singleton (Configure Once, Use Everywhere)
49
+
50
+ The package ships a built-in global `formatter` singleton. Call `configureFormatter()` **once** at app startup, then import `formatter` in any file - no extra setup needed.
51
+
52
+ ### Step 1: Configure once at app entry
53
+
54
+ ```typescript
55
+ // Call at your app entry point (main.tsx or main.ts)
56
+ // Only needs to be called ONCE - the formatter will be available globally
57
+ import { configureFormatter } from "@wikytam/helpers"
58
+
59
+ configureFormatter({
60
+ locale: "vi-VN",
61
+ timeZone: "Asia/Ho_Chi_Minh",
62
+ currencyCode: "VND",
63
+ booleanFormat: ["Không", "Có"], // Vietnamese: [false, true] labels
64
+ nullDisplay: "(chua dat)", // Vietnamese: shown for null/undefined
65
+ decimalSeparator: ",",
66
+ thousandSeparator: ".",
67
+ })
68
+ ```
69
+
70
+ ### Step 2: Use anywhere - just import `formatter`
71
+
72
+ ```typescript
73
+ // ANY page, component, or service - just import and use
74
+ // No need to pass instances, no need to create wrapper files
75
+ import { formatter } from "@wikytam/helpers"
76
+
77
+ formatter.asCurrency(1234567) // "1.234.567 d"
78
+ formatter.asDate("2024-03-15") // "15 thg 3, 2024"
79
+ formatter.asBoolean(true) // "Có"
80
+ formatter.asSpellout(42) // "bốn mươi hai"
81
+ formatter.asNumberShort(5000000) // "5,0 Trieu"
82
+ ```
83
+
84
+ ```typescript
85
+ // Works in backend services too
86
+ import { formatter } from "@wikytam/helpers"
87
+
88
+ formatter.asDuration(5400) // "1 giờ, 30 phút"
89
+ formatter.asRelativeTime(createdAt) // "2 ngày trước"
90
+ ```
91
+
92
+ One `configureFormatter()` call at startup, then `formatter` works everywhere.
93
+
94
+ ### Override for a specific page
95
+
96
+ When one page needs different settings, create a local instance:
97
+
98
+ ```typescript
99
+ import { Formatter } from "@wikytam/helpers"
100
+
101
+ // Local instance with different settings - does not affect the global singleton
102
+ const exportFormatter = new Formatter({
103
+ locale: "en-US",
104
+ currencyCode: "USD",
105
+ })
106
+
107
+ export function ExportPage() {
108
+ return <span>{exportFormatter.asCurrency(1234.56)}</span> // "$1,234.56"
109
+ }
110
+ ```
111
+
112
+
113
+
114
+ ## Configuration
115
+
116
+ All options are optional with sensible defaults:
117
+
118
+ ```typescript
119
+ const f = new Formatter({
120
+ locale: "vi-VN", // Intl locale (default: "en-US")
121
+ timeZone: "Asia/Ho_Chi_Minh", // Output timezone (default: "UTC")
122
+ defaultTimeZone: "UTC", // Assumed TZ for inputs without timezone
123
+ dateFormat: "medium", // Default date format preset or Intl options
124
+ timeFormat: "medium", // Default time format preset or Intl options
125
+ datetimeFormat: "medium", // Default datetime format preset or Intl options
126
+ booleanFormat: ["No", "Yes"], // [falsy, truthy] labels
127
+ nullDisplay: "(not set)", // Shown for null/undefined values
128
+ currencyCode: "VND", // ISO 4217 currency code
129
+ decimalSeparator: ",", // Custom decimal separator (null = locale default)
130
+ thousandSeparator: ".", // Custom thousand separator (null = locale default)
131
+ currencyDecimalSeparator: null, // Custom decimal for currency (null = locale default)
132
+ sizeFormatBase: 1024, // 1024 (binary) or 1000 (decimal)
133
+ systemOfUnits: "metric", // "metric" or "imperial"
134
+ defaultDecimalDigits: null, // Default fraction digits (null = auto per method)
135
+ })
136
+ ```
137
+
138
+
139
+
140
+ ### Date Format Presets
141
+
142
+ The `dateFormat`, `timeFormat`, and `datetimeFormat` options accept either a preset string or an `Intl.DateTimeFormatOptions` object:
143
+
144
+ | Preset | Date Example | Time Example |
145
+ | ---------- | -------------------- | ---------------- |
146
+ | `"short"` | `3/15/24` | `2:30 PM` |
147
+ | `"medium"` | `Mar 15, 2024` | `2:30:45 PM` |
148
+ | `"long"` | `March 15, 2024` | `2:30:45 PM UTC` |
149
+ | `"full"` | `Friday, March 15..` | `2:30:45 PM ..` |
150
+
151
+
152
+
153
+ ## Generic `format()` Method
154
+
155
+ Like Yii2, you can dispatch dynamically by format name:
156
+
157
+ ```typescript
158
+ f.format(value, "date") // calls asDate(value)
159
+ f.format(value, "integer") // calls asInteger(value)
160
+ f.format(value, ["decimal", 3]) // calls asDecimal(value, 3)
161
+ f.format(value, ["currency", "EUR"]) // calls asCurrency(value, "EUR")
162
+ f.format(null, "text") // returns nullDisplay
163
+ ```
164
+
165
+
166
+
167
+ ## API Reference
168
+
169
+
170
+
171
+ ### String & HTML
172
+
173
+ | Method | Description | Signature |
174
+ | ---------------- | ------------------------------------------------------------ | ----------------------------------- |
175
+ | `asRaw()` | Returns the value as-is without any formatting. | `asRaw(value)` |
176
+ | `asText()` | HTML-encodes the value as plain text. | `asText(value)` |
177
+ | `asNtext()` | HTML-encodes with newlines (`\n`, `\r\n`, `\r`) as `<br />`.| `asNtext(value)` |
178
+ | `asParagraphs()` | Splits by double newlines into paragraphs. | `asParagraphs(value, options?)` |
179
+ | `asHtml()` | Returns HTML, optionally sanitized via allowlist. | `asHtml(value, sanitize?)` |
180
+ | `asEmail()` | Creates a mailto link with optional subject/body. | `asEmail(value, options?)` |
181
+ | `asUrl()` | Creates a hyperlink with rel, class, text options. | `asUrl(value, options?)` |
182
+ | `asImage()` | Creates an `<img>` tag with width/height/class/loading. | `asImage(value, options?)` |
183
+ | `asBoolean()` | Formats as boolean using configured labels. | `asBoolean(value)` |
184
+
185
+ ```typescript
186
+ // Basic formatting
187
+ f.asRaw("<b>hello</b>") // "<b>hello</b>"
188
+ f.asText("<b>hello</b>") // "&lt;b&gt;hello&lt;/b&gt;"
189
+ f.asNtext("a\nb") // "a<br />b"
190
+ f.asNtext("a\r\nb") // "a<br />b" (Windows line endings)
191
+ f.asNtext("a\n\nb") // "a<br /><br />b" (consecutive newlines preserved)
192
+ f.asParagraphs("p1\n\np2") // "<p>p1</p>\n<p>p2</p>"
193
+ f.asBoolean(true) // "Yes"
194
+
195
+ // asParagraphs options
196
+ f.asParagraphs("A\n\nB", { tag: "div" }) // "<div>A</div>\n<div>B</div>"
197
+ f.asParagraphs("a\nb\n\nc", { lineBreaks: true }) // "<p>a<br />b</p>\n<p>c</p>"
198
+
199
+ // asHtml with sanitizer
200
+ f.asHtml("<p>safe</p>") // "<p>safe</p>" (no sanitize config = pass-through)
201
+ f.asHtml('<p>ok</p><script>bad</script>', {
202
+ allowedTags: ["p", "b", "i"],
203
+ }) // '<p>ok</p>bad'
204
+ f.asHtml('<a href="x" onclick="evil()">Link</a>', {
205
+ allowedTags: ["a"],
206
+ allowedAttributes: { a: ["href"] },
207
+ }) // '<a href="x">Link</a>'
208
+
209
+ // asEmail options
210
+ f.asEmail("a@b.com") // '<a href="mailto:a@b.com">a@b.com</a>'
211
+ f.asEmail("a@b.com", { text: "Contact us" }) // '<a href="mailto:a@b.com">Contact us</a>'
212
+ f.asEmail("a@b.com", { subject: "Hi", body: "Hello" })
213
+ // '<a href="mailto:a@b.com?subject=Hi&body=Hello">a@b.com</a>'
214
+ f.asEmail("invalid-email") // "invalid-email" (plain text for invalid)
215
+
216
+ // asUrl options
217
+ f.asUrl("example.com") // '<a href="http://example.com" target="_blank">example.com</a>'
218
+ f.asUrl("ftp://files.example.com") // detects ftp:// scheme
219
+ f.asUrl("https://x.com", { text: "Visit", rel: "noopener", class: "link" })
220
+ // '<a href="https://x.com" target="_blank" rel="noopener" class="link">Visit</a>'
221
+
222
+ // asImage options
223
+ f.asImage("/pic.jpg") // '<img src="/pic.jpg" alt="" />'
224
+ f.asImage("/pic.jpg", { alt: "Photo", width: 200, height: 150 })
225
+ // '<img src="/pic.jpg" alt="Photo" width="200" height="150" />'
226
+ f.asImage("/pic.jpg", { class: "rounded", loading: "lazy" })
227
+ // '<img src="/pic.jpg" alt="" class="rounded" loading="lazy" />'
228
+ ```
229
+
230
+ #### Option Types
231
+
232
+ ```typescript
233
+ interface EmailOptions {
234
+ text?: string // Custom display text
235
+ subject?: string // Email subject
236
+ body?: string // Email body
237
+ }
238
+
239
+ interface UrlOptions {
240
+ target?: string // Link target (default: "_blank")
241
+ text?: string // Custom display text
242
+ rel?: string // Rel attribute
243
+ class?: string // CSS class(es)
244
+ }
245
+
246
+ interface ImageOptions {
247
+ alt?: string // Alt text
248
+ width?: number | string // Width
249
+ height?: number | string // Height
250
+ class?: string // CSS class(es)
251
+ loading?: "lazy" | "eager" // Loading strategy
252
+ }
253
+
254
+ interface ParagraphOptions {
255
+ tag?: string // Wrapper tag (default: "p")
256
+ lineBreaks?: boolean // Convert \n to <br /> within paragraphs (default: false)
257
+ }
258
+
259
+ interface HtmlSanitizeConfig {
260
+ allowedTags?: string[] // Allowed HTML tags
261
+ allowedAttributes?: Record<string, string[]> // Allowed attributes per tag
262
+ }
263
+ ```
264
+
265
+
266
+
267
+ ### Number & Currency
268
+
269
+ | Method | Description | Signature |
270
+ | ---------------- | -------------------------------------------------- | -------------------------------- |
271
+ | `asInteger()` | Formats as integer (truncates, no rounding). | `asInteger(value)` |
272
+ | `asDecimal()` | Formats as decimal number. | `asDecimal(value, decimals?)` |
273
+ | `asPercent()` | Formats as percent with "%" sign. | `asPercent(value, decimals?)` |
274
+ | `asCurrency()` | Formats as currency using ISO 4217 codes. | `asCurrency(value, currency?)` |
275
+ | `asScientific()` | Formats as scientific notation (e-notation). | `asScientific(value, decimals?)` |
276
+ | `asSpellout()` | Spells out a number in words. | `asSpellout(value)` |
277
+ | `asOrdinal()` | Formats as ordinal (e.g. "1st", "2nd"). | `asOrdinal(value)` |
278
+
279
+ ```typescript
280
+ f.asInteger(1234.99) // "1,234"
281
+ f.asDecimal(1234.5) // "1,234.50"
282
+ f.asDecimal(1234.5, 3) // "1,234.500"
283
+ f.asPercent(0.156, 1) // "15.6%"
284
+ f.asCurrency(1234.56) // "$1,234.56"
285
+ f.asCurrency(1234, "EUR") // "EUR 1,234.00" (varies by locale)
286
+ f.asScientific(1234567) // "1.23E6"
287
+ f.asSpellout(42) // "forty-two"
288
+ f.asOrdinal(3) // "3rd"
289
+ ```
290
+
291
+ #### Ordinal Locale Support
292
+
293
+ Built-in ordinal suffixes for: `en`, `vi`, `fr`, `de`, `es`, `pt`, `it`, `ja`, `ko`, `zh`.
294
+
295
+ Add custom ordinal suffixes at runtime:
296
+
297
+ ```typescript
298
+ Formatter.registerOrdinalSuffixes("nl", { other: "e" })
299
+
300
+ const fNl = new Formatter({ locale: "nl-NL" })
301
+ fNl.asOrdinal(1) // "1e"
302
+ fNl.asOrdinal(5) // "5e"
303
+ ```
304
+
305
+
306
+
307
+ ### Date & Time
308
+
309
+ | Method | Description | Signature |
310
+ | ------------------ | --------------------------------------------- | --------------------------------------- |
311
+ | `asDate()` | Formats as date. | `asDate(value, format?)` |
312
+ | `asTime()` | Formats as time. | `asTime(value, format?)` |
313
+ | `asDatetime()` | Formats as datetime. | `asDatetime(value, format?)` |
314
+ | `asTimestamp()` | Converts to UNIX timestamp (seconds). | `asTimestamp(value)` |
315
+ | `asRelativeTime()` | Human-readable time interval from now. | `asRelativeTime(value, referenceTime?)` |
316
+ | `asDuration()` | Human-readable duration. | `asDuration(value, implode?)` |
317
+
318
+ ```typescript
319
+ f.asDate("2024-03-15") // "Mar 15, 2024"
320
+ f.asDate("2024-03-15", "long") // "March 15, 2024"
321
+ f.asDate("2024-03-15", { year: "numeric", month: "2-digit", day: "2-digit" })
322
+ // "03/15/2024"
323
+ f.asTime(date) // "2:30:45 PM"
324
+ f.asDatetime(date) // "Mar 15, 2024, 2:30:45 PM"
325
+ f.asTimestamp("2024-03-15T14:30:45.000Z") // "1710513045"
326
+ f.asRelativeTime(twoDaysAgo) // "2 days ago"
327
+ f.asRelativeTime(date, referenceDate) // "in 2 hours"
328
+ f.asDuration(5400) // "1 hour, 30 minutes"
329
+ f.asDuration(90061) // "1 day, 1 hour, 1 minute, 1 second"
330
+
331
+ // UNIX timestamps are auto-detected and converted
332
+ f.asDate(1710513045) // "Mar 15, 2024" (seconds -> auto-convert)
333
+ f.asDate(1710513045000) // "Mar 15, 2024" (milliseconds -> auto-convert)
334
+ f.asDatetime(1710513045) // "Mar 15, 2024, 2:30:45 PM"
335
+ ```
336
+
337
+ Date inputs accept: `Date` objects, UNIX timestamps (seconds or milliseconds - auto-detected), ISO 8601 strings.
338
+ Numbers below `1e12` are treated as seconds, above as milliseconds.
339
+
340
+ ### Size & Measurement
341
+
342
+ | Method | Description | Signature |
343
+ | ----------------- | -------------------------------------------------------- | --------------------------------- |
344
+ | `asSize()` | Formats bytes as size (e.g. `12 kilobytes`). | `asSize(value, decimals?)` |
345
+ | `asShortSize()` | Formats bytes as short size (e.g. `12 kB`). | `asShortSize(value, decimals?)` |
346
+ | `asLength()` | Formats length (e.g. `12 meters`). | `asLength(value, decimals?)` |
347
+ | `asShortLength()` | Formats short length (e.g. `12 m`). | `asShortLength(value, decimals?)` |
348
+ | `asWeight()` | Formats weight (e.g. `12 kilograms`). | `asWeight(value, decimals?)` |
349
+ | `asShortWeight()` | Formats short weight (e.g. `12 kg`). | `asShortWeight(value, decimals?)` |
350
+
351
+ ```typescript
352
+ // File sizes (base 1024 by default)
353
+ f.asSize(1536) // "1.5 kilobytes"
354
+ f.asShortSize(1048576) // "1 MB"
355
+ f.asShortSize(1073741824) // "1 GB"
356
+
357
+ // Metric lengths (default)
358
+ f.asLength(1500) // "1.5 meters"
359
+ f.asShortLength(5000000) // "5 km"
360
+
361
+ // Metric weights (default)
362
+ f.asWeight(1500) // "1.5 kilograms"
363
+ f.asShortWeight(5000000) // "5 t"
364
+
365
+ // Imperial system
366
+ const fImp = new Formatter({ systemOfUnits: "imperial" })
367
+ fImp.asLength(24) // "2 feet"
368
+ fImp.asWeight(14000) // "2 pounds"
369
+
370
+ // Decimal base for file sizes
371
+ const f1000 = new Formatter({ sizeFormatBase: 1000 })
372
+ f1000.asShortSize(1500) // "1.5 KB"
373
+ ```
374
+
375
+
376
+
377
+ ### Utility Methods
378
+
379
+ | Method | Description | Signature |
380
+ | -------------------- | ------------------------------------------------- | -------------------------------------------------- |
381
+ | `asNumberShort()` | Abbreviates large numbers with locale suffixes. | `asNumberShort(value, options?)` |
382
+ | `asGpsDistance()` | Formatted distance between GPS coordinates. | `asGpsDistance(lat1, lon1, lat2, lon2, options?)` |
383
+ | `asMaskedValue()` | Masks a string, showing first/last N characters. | `asMaskedValue(value, options?)` |
384
+
385
+ ```typescript
386
+ // Abbreviate large numbers
387
+ f.asNumberShort(1_500_000) // "1.5 Million"
388
+ f.asNumberShort(2_300_000_000) // "2.3 Billion"
389
+ f.asNumberShort(999) // "$999.00" (falls back to currency)
390
+ f.asNumberShort(42_000, { spaceBefore: true }) // "42.0 K"
391
+ f.asNumberShort(500, { fallback: "decimal" }) // "500.0"
392
+ f.asNumberShort(500, { fallback: "integer" }) // "500"
393
+ f.asNumberShort(1_234_567, { decimals: 2 }) // "1.23 Million"
394
+
395
+ const fVi = new Formatter({ locale: "vi-VN", currencyCode: "VND" })
396
+ fVi.asNumberShort(5_000_000) // "5,0 Trieu"
397
+ fVi.asNumberShort(1_500_000_000_000) // "1,5 Nghin Ty"
398
+
399
+ // GPS distance (formatted with units)
400
+ f.asGpsDistance(40.7128, -74.006, 34.0522, -118.2437) // "3,944.4 km" (auto)
401
+ f.asGpsDistance(40.7128, -74.006, 34.0522, -118.2437, { unit: "mi" }) // "2,450.8 mi"
402
+ f.asGpsDistance(10, 20, 10.001, 20) // "111.2 m" (auto: short distance)
403
+ f.asGpsDistance(10, 20, 11, 20, { unit: "km", decimals: 3 }) // "111.195 km"
404
+
405
+ // Mask sensitive data
406
+ f.asMaskedValue("0901234567") // "0901XXX567"
407
+ f.asMaskedValue("4111111111111111", { startVisible: 4, endVisible: 4 }) // "4111XXXXXXXX1111"
408
+ f.asMaskedValue("secret", { startVisible: 2, endVisible: 2, maskChar: "*" }) // "se**et"
409
+ f.asMaskedValue(null) // "(not set)"
410
+ ```
411
+
412
+ #### Option Types
413
+
414
+ ```typescript
415
+ interface NumberShortOptions {
416
+ decimals?: number // Decimal places (default: 1)
417
+ fallback?: "currency" | "decimal" | "integer" // Below threshold (default: "currency")
418
+ spaceBefore?: boolean // Space between number and suffix (default: false)
419
+ }
420
+
421
+ interface GpsDistanceOptions {
422
+ unit?: "m" | "km" | "mi" | "auto" // Output unit (default: "auto")
423
+ decimals?: number // Decimal places (default: 1)
424
+ earthRadius?: number // Earth radius in meters (default: 6371000)
425
+ }
426
+
427
+ interface MaskOptions {
428
+ startVisible?: number // Visible chars at start (default: 4)
429
+ endVisible?: number // Visible chars at end (default: 3)
430
+ maskChar?: string // Mask character (default: "X")
431
+ }
432
+ ```
433
+
434
+
435
+
436
+ ## Null Handling
437
+
438
+ All methods return `nullDisplay` when the value is `null` or `undefined`:
439
+
440
+ ```typescript
441
+ const f = new Formatter({ nullDisplay: "N/A" })
442
+ f.asText(null) // "N/A"
443
+ f.asInteger(null) // "N/A"
444
+ f.asDate(undefined) // "N/A"
445
+ ```
446
+
447
+
448
+
449
+ ## Multi-locale Examples
450
+
451
+ ```typescript
452
+ // Vietnamese
453
+ const fVi = new Formatter({
454
+ locale: "vi-VN",
455
+ timeZone: "Asia/Ho_Chi_Minh",
456
+ currencyCode: "VND",
457
+ booleanFormat: ["Khong", "Co"],
458
+ })
459
+ fVi.asCurrency(1234567) // "1.234.567 d"
460
+
461
+ // Japanese
462
+ const fJa = new Formatter({
463
+ locale: "ja-JP",
464
+ timeZone: "Asia/Tokyo",
465
+ currencyCode: "JPY",
466
+ })
467
+ fJa.asCurrency(1234) // "Y1,234"
468
+ ```
469
+
470
+
471
+
472
+ ## Multi-locale Spellout
473
+
474
+ The `asSpellout()` method supports multiple languages via a pluggable locale registry:
475
+
476
+ ```typescript
477
+ // English (built-in)
478
+ const fEn = new Formatter({ locale: "en-US" })
479
+ fEn.asSpellout(42) // "forty-two"
480
+ fEn.asSpellout(1234567) // "one million two hundred thirty-four thousand five hundred sixty-seven"
481
+
482
+ // Vietnamese (built-in)
483
+ const fVi = new Formatter({ locale: "vi-VN" })
484
+ fVi.asSpellout(42) // "bon muoi hai"
485
+ fVi.asSpellout(1500) // "mot nghin nam tram"
486
+ fVi.asSpellout(1000000) // "mot trieu"
487
+ ```
488
+
489
+
490
+
491
+ ### Adding a Custom Locale
492
+
493
+ ```typescript
494
+ import { registerSpellout, registerNumberShort } from "@wikytam/helpers"
495
+ import type { LocaleSpellout, NumberShortConfig } from "@wikytam/helpers"
496
+
497
+ const jaSpellout: LocaleSpellout = {
498
+ zeroWord: "zero",
499
+ pointWord: "ten",
500
+ negativePrefix: "mainasu",
501
+ integerToWords: (n) => { /* ... */ },
502
+ digitToWord: (d) => { /* ... */ },
503
+ }
504
+
505
+ registerSpellout("ja", jaSpellout)
506
+
507
+ // Now Formatter with locale "ja-JP" will use your implementation
508
+ ```
509
+
510
+
511
+
512
+ ### Locale Directory Structure
513
+
514
+ ```
515
+ src/locales/
516
+ types.ts - LocaleSpellout / NumberShortConfig interfaces
517
+ en.ts - English spellout + number-short
518
+ vi.ts - Vietnamese spellout + number-short
519
+ index.ts - Registry with getSpellout(), registerSpellout(), etc.
520
+ ```
521
+
522
+
523
+
524
+ ## Build
525
+
526
+ ```bash
527
+ pnpm build # Build ESM + CJS via tsdown
528
+ pnpm test # Run unit tests
529
+ pnpm type-check # TypeScript type checking
530
+ ```
531
+
532
+
533
+
534
+ ## Exported Utilities
535
+
536
+ In addition to the `Formatter` class, the package exports helper functions:
537
+
538
+ ```typescript
539
+ import { escapeHtml, normalizeDate, normalizeNumber } from "@wikytam/helpers"
540
+
541
+ escapeHtml('<script>') // "&lt;script&gt;"
542
+ normalizeDate(1710513045) // Date object
543
+ normalizeNumber("1,234.56") // 1234.56
544
+ ```
545
+
546
+ ```typescript
547
+ import { registerSpellout, registerNumberShort, getSpellout } from "@wikytam/helpers"
548
+
549
+ // Register runtime locale, get locale provider
550
+ registerSpellout("ja", myJaSpellout)
551
+ const sp = getSpellout("ja")
552
+ ```
553
+
554
+
555
+
556
+ ## Differences from Yii2
557
+
558
+ | Feature | Yii2 | This Package |
559
+ | --------------------- | ------------------- | ---------------------------------------------- |
560
+ | Dependencies | PHP intl extension | Built-in Intl API (zero deps) |
561
+ | `asSpellout()` | ICU spellout | Locale registry (en + vi built-in, extensible) |
562
+ | `asHtml()` | HTMLPurifier | Built-in allowlist sanitizer + pass-through |
563
+ | `asNtext()` | Basic newlines | Handles `\r\n`, `\r`, `\n` + consecutive |
564
+ | `asParagraphs()` | Fixed `<p>` tag | Configurable tag + inline `<br />` option |
565
+ | `asEmail()` | Basic mailto | Subject, body, text, email validation |
566
+ | `asUrl()` | Basic href | rel, class, text, ftp/mailto scheme detection |
567
+ | `asImage()` | Basic img | width, height, class, loading attributes |
568
+ | `asOrdinal()` | ICU ordinal | 10 built-in locales + runtime registration |
569
+ | Date formats | ICU patterns | Intl presets or `Intl.DateTimeFormatOptions` |
570
+ | Config | PHP array | TypeScript `FormatterOptions` interface |
571
+ | `asNumberShort()` | Custom Yii2 helper | Instance method + options (fallback, spaceBefore)|
572
+ | `asGpsDistance()` | Custom Yii2 helper | Instance method + unit/decimals options |
573
+ | `asMaskedValue()` | Custom Yii2 helper | Instance method + null handling + options |
574
+ | Date inputs | Only Date/string | Auto-detects UNIX timestamps (s or ms) |