cakkatrok-indonesia-utils 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 Rintis Pameling
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,368 @@
1
+ # @rintisid/indonesia-utils
2
+
3
+ Utility functions for Indonesian JavaScript/Node.js projects.
4
+
5
+ This package is intentionally dependency-free and focused on common local use cases: Rupiah formatting, Indonesian phone numbers, Indonesian dates, slugs, unique payment amounts, and simple validation.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @rintisid/indonesia-utils
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ const {
17
+ formatRupiah,
18
+ parseRupiah,
19
+ normalizePhoneID,
20
+ isValidPhoneID,
21
+ formatPhoneID,
22
+ getProviderID,
23
+ slugifyID,
24
+ formatTanggalID,
25
+ relativeTimeID,
26
+ createUniqueAmount,
27
+ isValidEmail,
28
+ cleanText,
29
+ titleCaseID,
30
+ maskEmail,
31
+ maskPhoneID
32
+ } = require('@rintisid/indonesia-utils');
33
+
34
+ console.log(formatRupiah(15000));
35
+ // Rp15.000
36
+
37
+ console.log(parseRupiah('Rp15.000'));
38
+ // 15000
39
+
40
+ console.log(normalizePhoneID('0812-3456-7890'));
41
+ // 6281234567890
42
+
43
+ console.log(isValidPhoneID('0812-3456-7890'));
44
+ // true
45
+
46
+ console.log(formatPhoneID('081234567890'));
47
+ // +62 812 3456 7890
48
+
49
+ console.log(getProviderID('081234567890'));
50
+ // Telkomsel
51
+
52
+ console.log(slugifyID('Produk Lokal Indonesia & UMKM!'));
53
+ // produk-lokal-indonesia-dan-umkm
54
+
55
+ console.log(formatTanggalID('2026-07-08T10:30:00', { includeTime: true }));
56
+ // Rabu Legi, 8 Juli 2026 10.30
57
+
58
+ console.log(formatTanggalID('07 01 2007'));
59
+ // Minggu Wage, 7 Januari 2007
60
+
61
+ console.log(relativeTimeID('2026-07-07T10:30:00', '2026-07-08T10:30:00'));
62
+ // 1 hari yang lalu
63
+
64
+ console.log(relativeTimeID('07 01 2007', '08 01 2007'));
65
+ // 1 hari yang lalu
66
+
67
+ console.log(relativeTimeID('07 01 2007', '08 03 2026'));
68
+ // 19 tahun lebih 2 bulan 1 hari yang lalu
69
+
70
+ console.log(relativeTimeID('07 01 2007', '09 03 2026'));
71
+ // 19 tahun lebih 2 bulan 2 hari yang lalu
72
+
73
+ console.log(createUniqueAmount({ amount: 50000, activeAmounts: [50001, 50002] }));
74
+ // { baseAmount: 50000, uniqueCode: 3, finalAmount: 50003, formatted: 'Rp50.003' }
75
+
76
+ console.log(isValidEmail('user@example.com'));
77
+ // true
78
+
79
+ console.log(cleanText(' halo\n\t dunia '));
80
+ // halo dunia
81
+
82
+ console.log(titleCaseID('cara membuat nasi goreng yang enak'));
83
+ // Cara Membuat Nasi Goreng yang Enak
84
+
85
+ console.log(maskEmail('rintis@example.com'));
86
+ // r****s@example.com
87
+
88
+ console.log(maskPhoneID('081234567890'));
89
+ // +6281*****7890
90
+ ```
91
+
92
+ ## CLI
93
+
94
+ After installing globally or running with `npx`, you can use:
95
+
96
+ ```bash
97
+ npx indonesia-utils rupiah 15000
98
+ npx indonesia-utils parse-rupiah "Rp15.000"
99
+ npx indonesia-utils phone 081234567890
100
+ npx indonesia-utils provider 081234567890
101
+ npx indonesia-utils slug "Produk Lokal Indonesia & UMKM!"
102
+ npx indonesia-utils date 2026-07-08
103
+ npx indonesia-utils date "07 01 2007"
104
+ ```
105
+
106
+ ## API
107
+
108
+ ### `formatRupiah(value, options?)`
109
+
110
+ Formats a number into Indonesian Rupiah format.
111
+
112
+ ```js
113
+ formatRupiah(15000); // Rp15.000
114
+ formatRupiah(15000, { space: true }); // Rp 15.000
115
+ formatRupiah(15000, { withSymbol: false }); // 15.000
116
+ ```
117
+
118
+ Options:
119
+
120
+ ```js
121
+ {
122
+ symbol: 'Rp',
123
+ withSymbol: true,
124
+ decimal: 0,
125
+ space: false
126
+ }
127
+ ```
128
+
129
+ ### `parseRupiah(value)`
130
+
131
+ Parses Rupiah text into a number.
132
+
133
+ ```js
134
+ parseRupiah('Rp15.000'); // 15000
135
+ parseRupiah('Rp15.000,50'); // 15000.5
136
+ ```
137
+
138
+ ### `normalizePhoneID(value, options?)`
139
+
140
+ Normalizes Indonesian phone numbers into `62` format.
141
+
142
+ ```js
143
+ normalizePhoneID('0812-3456-7890'); // 6281234567890
144
+ normalizePhoneID('+62 812 3456 7890'); // 6281234567890
145
+ normalizePhoneID('81234567890'); // 6281234567890
146
+ normalizePhoneID('081234567890', { keepPlus: true }); // +6281234567890
147
+ ```
148
+
149
+ ### `isValidPhoneID(value)`
150
+
151
+ Validates Indonesian mobile phone number format.
152
+
153
+ ```js
154
+ isValidPhoneID('081234567890'); // true
155
+ ```
156
+
157
+ ### `formatPhoneID(value, options?)`
158
+
159
+ Formats a normalized Indonesian phone number for display.
160
+
161
+ ```js
162
+ formatPhoneID('081234567890'); // +62 812 3456 7890
163
+ formatPhoneID('081234567890', { international: false }); // 0 812 3456 7890
164
+ formatPhoneID('081234567890', { separator: '-' }); // +62-812-3456-7890
165
+ ```
166
+
167
+ ### `getProviderID(value)`
168
+
169
+ Detects common Indonesian mobile provider from phone prefix.
170
+
171
+ ```js
172
+ getProviderID('081234567890'); // Telkomsel
173
+ ```
174
+
175
+ Supported common providers:
176
+
177
+ - Telkomsel
178
+ - Indosat
179
+ - XL
180
+ - Axis
181
+ - Tri
182
+ - Smartfren
183
+
184
+ Unknown prefixes return `Unknown`.
185
+
186
+ ### `slugifyID(value, options?)`
187
+
188
+ Creates clean slugs.
189
+
190
+ ```js
191
+ slugifyID('Produk Lokal Indonesia & UMKM!');
192
+ // produk-lokal-indonesia-dan-umkm
193
+ ```
194
+
195
+ Options:
196
+
197
+ ```js
198
+ {
199
+ separator: '-',
200
+ lower: true,
201
+ maxLength: 0,
202
+ replacement: {
203
+ '&': ' dan ',
204
+ '+': ' plus ',
205
+ '@': ' at '
206
+ }
207
+ }
208
+ ```
209
+
210
+ ### `formatTanggalID(value?, options?)`
211
+
212
+ Formats a date in Indonesian.
213
+
214
+ Supported string input formats are ISO date strings and Indonesian date strings like `DD MM YYYY`.
215
+
216
+ ```js
217
+ formatTanggalID('2026-07-08');
218
+ // Rabu Legi, 8 Juli 2026
219
+
220
+ formatTanggalID('07 01 2007');
221
+ // Minggu Wage, 7 Januari 2007
222
+
223
+ formatTanggalID('2026-07-08T10:30:00', { includeTime: true });
224
+ // Rabu Legi, 8 Juli 2026 10.30
225
+
226
+ formatTanggalID('2026-07-08', { dateStyle: 'numeric' });
227
+ // Rabu Legi, 08/07/2026
228
+
229
+ formatTanggalID('2026-07-08', { includeWeton: false });
230
+ // Rabu, 8 Juli 2026
231
+ ```
232
+
233
+ Options:
234
+
235
+ ```js
236
+ {
237
+ includeDay: true,
238
+ shortMonth: false,
239
+ includeTime: false,
240
+ timeSeparator: '.',
241
+ dateStyle: 'long',
242
+ includeWeton: true
243
+ }
244
+ ```
245
+
246
+ ### `relativeTimeID(value, baseValue?)`
247
+
248
+ Returns Indonesian relative time.
249
+
250
+ `value` and `baseValue` support the same two date input formats as `formatTanggalID`.
251
+
252
+ ```js
253
+ relativeTimeID('2026-07-07T10:30:00', '2026-07-08T10:30:00');
254
+ // 1 hari yang lalu
255
+
256
+ relativeTimeID('07 01 2007', '08 01 2007');
257
+ // 1 hari yang lalu
258
+
259
+ relativeTimeID('07 01 2007', '08 03 2026');
260
+ // 19 tahun lebih 2 bulan 1 hari yang lalu
261
+
262
+ relativeTimeID('07 01 2007', '09 03 2026');
263
+ // 19 tahun lebih 2 bulan 2 hari yang lalu
264
+ ```
265
+
266
+ ### `createUniqueAmount(options)`
267
+
268
+ Creates unique payment amount for manual transfer or QRIS mutation matching.
269
+
270
+ ```js
271
+ createUniqueAmount({
272
+ amount: 50000,
273
+ activeAmounts: [50001, 50002]
274
+ });
275
+
276
+ // {
277
+ // baseAmount: 50000,
278
+ // uniqueCode: 3,
279
+ // finalAmount: 50003,
280
+ // formatted: 'Rp50.003'
281
+ // }
282
+ ```
283
+
284
+ Options:
285
+
286
+ ```js
287
+ {
288
+ amount: 50000,
289
+ min: 1,
290
+ max: 999,
291
+ activeAmounts: [],
292
+ mode: 'add' // or 'subtract'
293
+ }
294
+ ```
295
+
296
+ ### `isValidEmail(value)`
297
+
298
+ Simple email validation.
299
+
300
+ ```js
301
+ isValidEmail('user@example.com'); // true
302
+ ```
303
+
304
+ ### `cleanText(value)`
305
+
306
+ Trims and normalizes whitespace.
307
+
308
+ ```js
309
+ cleanText(' halo\n dunia '); // halo dunia
310
+ ```
311
+
312
+ ### `titleCaseID(value)`
313
+
314
+ Converts text into Indonesian-style title case.
315
+
316
+ ```js
317
+ titleCaseID('cara membuat nasi goreng yang enak');
318
+ // Cara Membuat Nasi Goreng yang Enak
319
+ ```
320
+
321
+ ### `maskEmail(value)`
322
+
323
+ Masks email username.
324
+
325
+ ```js
326
+ maskEmail('rintis@example.com'); // r****s@example.com
327
+ ```
328
+
329
+ ### `maskPhoneID(value)`
330
+
331
+ Masks Indonesian phone number.
332
+
333
+ ```js
334
+ maskPhoneID('081234567890'); // +6281*****7890
335
+ ```
336
+
337
+ ## Test
338
+
339
+ ```bash
340
+ npm test
341
+ ```
342
+
343
+ ## Publish
344
+
345
+ Before publishing, make sure the package name is available or use your own npm scope.
346
+
347
+ ```bash
348
+ npm login
349
+ npm publish --access public
350
+ ```
351
+
352
+ If you want to publish without scope, change this in `package.json`:
353
+
354
+ ```json
355
+ {
356
+ "name": "indonesia-utils"
357
+ }
358
+ ```
359
+
360
+ Then publish:
361
+
362
+ ```bash
363
+ npm publish
364
+ ```
365
+
366
+ ## License
367
+
368
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "cakkatrok-indonesia-utils",
3
+ "version": "1.0.0",
4
+ "description": "Utility functions for Indonesian projects: Rupiah, phone number, date, slug, unique payment amount, and validation.",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "bin": {
8
+ "indonesia-utils": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "example": "node examples/basic.js"
13
+ },
14
+ "keywords": [
15
+ "indonesia",
16
+ "rupiah",
17
+ "idr",
18
+ "phone",
19
+ "validator",
20
+ "slugify",
21
+ "tanggal",
22
+ "utility",
23
+ "utils"
24
+ ],
25
+ "author": "RintisW.P",
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=16"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ]
35
+ }
package/src/cli.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const utils = require('./index');
5
+
6
+ const [, , command, ...args] = process.argv;
7
+
8
+ function help() {
9
+ console.log(`indonesia-utils
10
+
11
+ Usage:
12
+ indonesia-utils rupiah <number>
13
+ indonesia-utils parse-rupiah <text>
14
+ indonesia-utils phone <number>
15
+ indonesia-utils provider <number>
16
+ indonesia-utils slug <text>
17
+ indonesia-utils date [date]
18
+
19
+ Examples:
20
+ indonesia-utils rupiah 15000
21
+ indonesia-utils phone 081234567890
22
+ indonesia-utils slug "Halo Dunia Indonesia"
23
+ `);
24
+ }
25
+
26
+ try {
27
+ switch (command) {
28
+ case 'rupiah':
29
+ console.log(utils.formatRupiah(args[0]));
30
+ break;
31
+ case 'parse-rupiah':
32
+ console.log(utils.parseRupiah(args.join(' ')));
33
+ break;
34
+ case 'phone':
35
+ console.log(utils.normalizePhoneID(args[0]));
36
+ break;
37
+ case 'provider':
38
+ console.log(utils.getProviderID(args[0]));
39
+ break;
40
+ case 'slug':
41
+ console.log(utils.slugifyID(args.join(' ')));
42
+ break;
43
+ case 'date':
44
+ console.log(utils.formatTanggalID(args[0] ? args.join(' ') : new Date()));
45
+ break;
46
+ case 'help':
47
+ case '--help':
48
+ case '-h':
49
+ case undefined:
50
+ help();
51
+ break;
52
+ default:
53
+ console.error(`Unknown command: ${command}`);
54
+ help();
55
+ process.exitCode = 1;
56
+ }
57
+ } catch (error) {
58
+ console.error(error.message);
59
+ process.exitCode = 1;
60
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export interface RupiahOptions {
2
+ symbol?: string;
3
+ withSymbol?: boolean;
4
+ decimal?: number;
5
+ space?: boolean;
6
+ }
7
+
8
+ export interface PhoneOptions {
9
+ countryCode?: string;
10
+ keepPlus?: boolean;
11
+ }
12
+
13
+ export interface FormatPhoneOptions {
14
+ separator?: string;
15
+ international?: boolean;
16
+ }
17
+
18
+ export interface SlugifyOptions {
19
+ separator?: string;
20
+ lower?: boolean;
21
+ maxLength?: number;
22
+ replacement?: Record<string, string>;
23
+ }
24
+
25
+ export interface FormatTanggalOptions {
26
+ includeDay?: boolean;
27
+ shortMonth?: boolean;
28
+ includeTime?: boolean;
29
+ timeSeparator?: string;
30
+ dateStyle?: 'long' | 'numeric';
31
+ includeWeton?: boolean;
32
+ }
33
+
34
+ export interface UniqueAmountOptions {
35
+ amount: number;
36
+ min?: number;
37
+ max?: number;
38
+ activeAmounts?: number[];
39
+ mode?: 'add' | 'subtract';
40
+ }
41
+
42
+ export interface UniqueAmountResult {
43
+ baseAmount: number;
44
+ uniqueCode: number;
45
+ finalAmount: number;
46
+ formatted: string;
47
+ }
48
+
49
+ export function formatRupiah(value: number | string, options?: RupiahOptions): string;
50
+ export function parseRupiah(value: number | string): number;
51
+ export function normalizePhoneID(value: string | number, options?: PhoneOptions): string;
52
+ export function isValidPhoneID(value: string | number): boolean;
53
+ export function formatPhoneID(value: string | number, options?: FormatPhoneOptions): string;
54
+ export function getProviderID(value: string | number): string;
55
+ export function slugifyID(value: string, options?: SlugifyOptions): string;
56
+ export function formatTanggalID(value?: Date | string | number, options?: FormatTanggalOptions): string;
57
+ export function relativeTimeID(value: Date | string | number, baseValue?: Date | string | number): string;
58
+ export function createUniqueAmount(options: UniqueAmountOptions): UniqueAmountResult;
59
+ export function isValidEmail(value: string): boolean;
60
+ export function cleanText(value: string): string;
61
+ export function titleCaseID(value: string): string;
62
+ export function maskEmail(value: string): string;
63
+ export function maskPhoneID(value: string | number): string;
package/src/index.js ADDED
@@ -0,0 +1,455 @@
1
+ 'use strict';
2
+
3
+ const MONTHS_LONG = [
4
+ 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
5
+ 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
6
+ ];
7
+
8
+ const MONTHS_SHORT = [
9
+ 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
10
+ 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
11
+ ];
12
+
13
+ const DAYS_LONG = [
14
+ 'Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'
15
+ ];
16
+
17
+ const PASARAN = ['Legi', 'Pahing', 'Pon', 'Wage', 'Kliwon'];
18
+ const PASARAN_ANCHOR = Date.UTC(1945, 7, 17);
19
+
20
+ const PROVIDER_PREFIXES = [
21
+ { name: 'Telkomsel', prefixes: ['811', '812', '813', '821', '822', '823', '851', '852', '853'] },
22
+ { name: 'Indosat', prefixes: ['814', '815', '816', '855', '856', '857', '858'] },
23
+ { name: 'XL', prefixes: ['817', '818', '819', '859', '877', '878'] },
24
+ { name: 'Axis', prefixes: ['831', '832', '833', '838'] },
25
+ { name: 'Tri', prefixes: ['895', '896', '897', '898', '899'] },
26
+ { name: 'Smartfren', prefixes: ['881', '882', '883', '884', '885', '886', '887', '888', '889'] }
27
+ ];
28
+
29
+ function assertNumber(value, name) {
30
+ const number = Number(value);
31
+ if (!Number.isFinite(number)) {
32
+ throw new TypeError(`${name} must be a finite number`);
33
+ }
34
+ return number;
35
+ }
36
+
37
+ function onlyDigits(value) {
38
+ return String(value ?? '').replace(/\D+/g, '');
39
+ }
40
+
41
+ function formatRupiah(value, options = {}) {
42
+ const {
43
+ symbol = 'Rp',
44
+ withSymbol = true,
45
+ decimal = 0,
46
+ space = false
47
+ } = options;
48
+
49
+ const number = assertNumber(value, 'value');
50
+ const formatted = new Intl.NumberFormat('id-ID', {
51
+ minimumFractionDigits: decimal,
52
+ maximumFractionDigits: decimal
53
+ }).format(number);
54
+
55
+ if (!withSymbol) return formatted;
56
+ return `${symbol}${space ? ' ' : ''}${formatted}`;
57
+ }
58
+
59
+ function parseRupiah(value) {
60
+ if (typeof value === 'number') return value;
61
+ if (value === null || value === undefined) return 0;
62
+
63
+ let text = String(value)
64
+ .trim()
65
+ .replace(/rp/gi, '')
66
+ .replace(/\s+/g, '');
67
+
68
+ const hasCommaDecimal = /,\d{1,2}$/.test(text);
69
+
70
+ if (hasCommaDecimal) {
71
+ text = text.replace(/\./g, '').replace(',', '.');
72
+ } else {
73
+ text = text.replace(/[.,]/g, '');
74
+ }
75
+
76
+ const number = Number(text.replace(/[^0-9.-]/g, ''));
77
+ return Number.isFinite(number) ? number : 0;
78
+ }
79
+
80
+ function normalizePhoneID(value, options = {}) {
81
+ const { countryCode = '62', keepPlus = false } = options;
82
+ let digits = onlyDigits(value);
83
+
84
+ if (!digits) return '';
85
+
86
+ if (digits.startsWith('00' + countryCode)) {
87
+ digits = digits.slice(2);
88
+ }
89
+
90
+ if (digits.startsWith('0')) {
91
+ digits = countryCode + digits.slice(1);
92
+ }
93
+
94
+ if (!digits.startsWith(countryCode) && digits.startsWith('8')) {
95
+ digits = countryCode + digits;
96
+ }
97
+
98
+ return keepPlus ? `+${digits}` : digits;
99
+ }
100
+
101
+ function isValidPhoneID(value) {
102
+ const phone = normalizePhoneID(value);
103
+ return /^628[1-9]\d{7,11}$/.test(phone);
104
+ }
105
+
106
+ function formatPhoneID(value, options = {}) {
107
+ const { separator = ' ', international = true } = options;
108
+ const phone = normalizePhoneID(value);
109
+
110
+ if (!phone) return '';
111
+ if (!phone.startsWith('62')) return phone;
112
+
113
+ const national = phone.slice(2);
114
+ const chunks = [];
115
+
116
+ chunks.push(national.slice(0, 3));
117
+ if (national.length > 3) chunks.push(national.slice(3, 7));
118
+ if (national.length > 7) chunks.push(national.slice(7, 11));
119
+ if (national.length > 11) chunks.push(national.slice(11));
120
+
121
+ const prefix = international ? '+62' : '0';
122
+ return `${prefix}${separator}${chunks.filter(Boolean).join(separator)}`;
123
+ }
124
+
125
+ function getProviderID(value) {
126
+ const phone = normalizePhoneID(value);
127
+ if (!phone.startsWith('62') || phone.length < 5) return 'Unknown';
128
+
129
+ const prefix = phone.slice(2, 5);
130
+ const found = PROVIDER_PREFIXES.find(provider => provider.prefixes.includes(prefix));
131
+ return found ? found.name : 'Unknown';
132
+ }
133
+
134
+ function slugifyID(value, options = {}) {
135
+ const {
136
+ separator = '-',
137
+ lower = true,
138
+ maxLength = 0,
139
+ replacement = {
140
+ '&': ' dan ',
141
+ '+': ' plus ',
142
+ '@': ' at '
143
+ }
144
+ } = options;
145
+
146
+ let text = String(value ?? '');
147
+
148
+ for (const [from, to] of Object.entries(replacement)) {
149
+ text = text.split(from).join(to);
150
+ }
151
+
152
+ text = text
153
+ .normalize('NFKD')
154
+ .replace(/[\u0300-\u036f]/g, '')
155
+ .replace(/[^\w\s-]/g, ' ')
156
+ .replace(/_/g, ' ')
157
+ .trim()
158
+ .replace(/\s+/g, separator)
159
+ .replace(new RegExp(`${escapeRegExp(separator)}+`, 'g'), separator);
160
+
161
+ if (lower) text = text.toLowerCase();
162
+ if (maxLength > 0) {
163
+ text = text.slice(0, maxLength).replace(new RegExp(`${escapeRegExp(separator)}$`), '');
164
+ }
165
+
166
+ return text;
167
+ }
168
+
169
+ function escapeRegExp(value) {
170
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
+ }
172
+
173
+ function toDate(value) {
174
+ if (value instanceof Date) {
175
+ const date = new Date(value.getTime());
176
+ if (Number.isNaN(date.getTime())) {
177
+ throw new TypeError('Invalid date');
178
+ }
179
+ return date;
180
+ }
181
+
182
+ if (typeof value === 'string') {
183
+ const text = value.trim();
184
+ const idDateMatch = text.match(/^(\d{1,2})[ /\-.](\d{1,2})[ /\-.](\d{4})(?:\s+(\d{1,2})[:.](\d{1,2}))?$/);
185
+
186
+ if (idDateMatch) {
187
+ const [, dayText, monthText, yearText, hourText = '0', minuteText = '0'] = idDateMatch;
188
+ const day = Number(dayText);
189
+ const month = Number(monthText);
190
+ const year = Number(yearText);
191
+ const hour = Number(hourText);
192
+ const minute = Number(minuteText);
193
+ const date = new Date(year, month - 1, day, hour, minute);
194
+
195
+ if (
196
+ date.getFullYear() !== year ||
197
+ date.getMonth() !== month - 1 ||
198
+ date.getDate() !== day ||
199
+ date.getHours() !== hour ||
200
+ date.getMinutes() !== minute
201
+ ) {
202
+ throw new TypeError('Invalid date');
203
+ }
204
+
205
+ return date;
206
+ }
207
+ }
208
+
209
+ const date = new Date(value);
210
+ if (Number.isNaN(date.getTime())) {
211
+ throw new TypeError('Invalid date');
212
+ }
213
+ return date;
214
+ }
215
+
216
+ function pad2(value) {
217
+ return String(value).padStart(2, '0');
218
+ }
219
+
220
+ function getPasaranID(value) {
221
+ const date = toDate(value);
222
+ const utcDate = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
223
+ const diffDays = Math.round((utcDate - PASARAN_ANCHOR) / (24 * 60 * 60 * 1000));
224
+ const index = ((diffDays % PASARAN.length) + PASARAN.length) % PASARAN.length;
225
+ return PASARAN[index];
226
+ }
227
+
228
+ function getWetonID(value) {
229
+ const date = toDate(value);
230
+ return `${DAYS_LONG[date.getDay()]} ${getPasaranID(date)}`;
231
+ }
232
+
233
+ function daysInMonth(year, month) {
234
+ return new Date(year, month + 1, 0).getDate();
235
+ }
236
+
237
+ function addCalendarMonths(value, months) {
238
+ const date = new Date(value.getTime());
239
+ const targetMonth = date.getMonth() + months;
240
+ const targetYear = date.getFullYear() + Math.floor(targetMonth / 12);
241
+ const normalizedMonth = ((targetMonth % 12) + 12) % 12;
242
+ const day = Math.min(date.getDate(), daysInMonth(targetYear, normalizedMonth));
243
+
244
+ return new Date(
245
+ targetYear,
246
+ normalizedMonth,
247
+ day,
248
+ date.getHours(),
249
+ date.getMinutes(),
250
+ date.getSeconds(),
251
+ date.getMilliseconds()
252
+ );
253
+ }
254
+
255
+ function getCalendarMonthDiff(from, to) {
256
+ let months = (to.getFullYear() - from.getFullYear()) * 12;
257
+ months += to.getMonth() - from.getMonth();
258
+
259
+ if (addCalendarMonths(from, months) > to) {
260
+ months -= 1;
261
+ }
262
+
263
+ return months;
264
+ }
265
+
266
+ function getRelativeDateParts(from, to) {
267
+ const totalMonths = getCalendarMonthDiff(from, to);
268
+ const afterMonths = addCalendarMonths(from, totalMonths);
269
+ const days = Math.floor((to.getTime() - afterMonths.getTime()) / (24 * 60 * 60 * 1000));
270
+
271
+ return {
272
+ years: Math.floor(totalMonths / 12),
273
+ months: totalMonths % 12,
274
+ totalMonths,
275
+ days
276
+ };
277
+ }
278
+
279
+ function formatRelativeDateParts(parts) {
280
+ const segments = [];
281
+
282
+ if (parts.years > 0) segments.push(`${parts.years} tahun`);
283
+ if (parts.months > 0) segments.push(`${parts.months} bulan`);
284
+ if (parts.days > 0) segments.push(`${parts.days} hari`);
285
+
286
+ if (segments.length <= 1) return segments[0] ?? '';
287
+ return `${segments[0]} lebih ${segments.slice(1).join(' ')}`;
288
+ }
289
+
290
+ function formatTanggalID(value = new Date(), options = {}) {
291
+ const {
292
+ includeDay = true,
293
+ shortMonth = false,
294
+ includeTime = false,
295
+ timeSeparator = '.',
296
+ dateStyle = 'long',
297
+ includeWeton = true
298
+ } = options;
299
+
300
+ const date = toDate(value);
301
+ const day = includeWeton ? getWetonID(date) : DAYS_LONG[date.getDay()];
302
+ const monthName = (shortMonth ? MONTHS_SHORT : MONTHS_LONG)[date.getMonth()];
303
+
304
+ let result;
305
+
306
+ if (dateStyle === 'numeric') {
307
+ result = `${pad2(date.getDate())}/${pad2(date.getMonth() + 1)}/${date.getFullYear()}`;
308
+ } else {
309
+ result = `${date.getDate()} ${monthName} ${date.getFullYear()}`;
310
+ }
311
+
312
+ if (includeDay) result = `${day}, ${result}`;
313
+
314
+ if (includeTime) {
315
+ result += ` ${pad2(date.getHours())}${timeSeparator}${pad2(date.getMinutes())}`;
316
+ }
317
+
318
+ return result;
319
+ }
320
+
321
+ function relativeTimeID(value, baseValue = new Date()) {
322
+ const date = toDate(value);
323
+ const base = toDate(baseValue);
324
+ const diffMs = date.getTime() - base.getTime();
325
+ const absMs = Math.abs(diffMs);
326
+ const earlier = diffMs < 0 ? date : base;
327
+ const later = diffMs < 0 ? base : date;
328
+ const dateParts = getRelativeDateParts(earlier, later);
329
+
330
+ const units = [
331
+ { name: 'minggu', ms: 7 * 24 * 60 * 60 * 1000 },
332
+ { name: 'hari', ms: 24 * 60 * 60 * 1000 },
333
+ { name: 'jam', ms: 60 * 60 * 1000 },
334
+ { name: 'menit', ms: 60 * 1000 },
335
+ { name: 'detik', ms: 1000 }
336
+ ];
337
+
338
+ if (absMs < 1000) return 'baru saja';
339
+
340
+ if (dateParts.totalMonths >= 12) {
341
+ const text = formatRelativeDateParts(dateParts);
342
+ if (diffMs < 0) return `${text} yang lalu`;
343
+ return `dalam ${text}`;
344
+ }
345
+
346
+ if (dateParts.totalMonths >= 1) {
347
+ const text = formatRelativeDateParts(dateParts);
348
+ if (diffMs < 0) return `${text} yang lalu`;
349
+ return `dalam ${text}`;
350
+ }
351
+
352
+ const unit = units.find(item => absMs >= item.ms) || units[units.length - 1];
353
+ const count = Math.floor(absMs / unit.ms);
354
+
355
+ if (diffMs < 0) return `${count} ${unit.name} yang lalu`;
356
+ return `dalam ${count} ${unit.name}`;
357
+ }
358
+
359
+ function createUniqueAmount(options = {}) {
360
+ const {
361
+ amount,
362
+ min = 1,
363
+ max = 999,
364
+ activeAmounts = [],
365
+ mode = 'add'
366
+ } = options;
367
+
368
+ const baseAmount = assertNumber(amount, 'amount');
369
+ const minCode = Math.max(0, Math.floor(assertNumber(min, 'min')));
370
+ const maxCode = Math.max(minCode, Math.floor(assertNumber(max, 'max')));
371
+ const active = new Set(activeAmounts.map(Number));
372
+
373
+ for (let code = minCode; code <= maxCode; code += 1) {
374
+ const finalAmount = mode === 'subtract' ? baseAmount - code : baseAmount + code;
375
+ if (finalAmount <= 0) continue;
376
+
377
+ if (!active.has(finalAmount)) {
378
+ return {
379
+ baseAmount,
380
+ uniqueCode: code,
381
+ finalAmount,
382
+ formatted: formatRupiah(finalAmount)
383
+ };
384
+ }
385
+ }
386
+
387
+ throw new Error('No available unique amount in the selected range');
388
+ }
389
+
390
+ function isValidEmail(value) {
391
+ return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(value ?? '').trim());
392
+ }
393
+
394
+ function cleanText(value) {
395
+ return String(value ?? '')
396
+ .replace(/[\t\n\r]+/g, ' ')
397
+ .replace(/\s+/g, ' ')
398
+ .trim();
399
+ }
400
+
401
+ function titleCaseID(value) {
402
+ const smallWords = new Set(['dan', 'di', 'ke', 'dari', 'yang', 'atau', 'untuk', 'dengan']);
403
+
404
+ return cleanText(value)
405
+ .toLowerCase()
406
+ .split(' ')
407
+ .map((word, index) => {
408
+ if (index > 0 && smallWords.has(word)) return word;
409
+ return word.charAt(0).toUpperCase() + word.slice(1);
410
+ })
411
+ .join(' ');
412
+ }
413
+
414
+ function maskEmail(value) {
415
+ const email = String(value ?? '').trim();
416
+ if (!isValidEmail(email)) return email;
417
+
418
+ const [name, domain] = email.split('@');
419
+ const maskedName = name.length <= 2
420
+ ? `${name[0] ?? ''}*`
421
+ : `${name[0]}${'*'.repeat(Math.min(name.length - 2, 5))}${name[name.length - 1]}`;
422
+
423
+ return `${maskedName}@${domain}`;
424
+ }
425
+
426
+ function maskPhoneID(value) {
427
+ const phone = normalizePhoneID(value, { keepPlus: true });
428
+ if (phone.length <= 8) return phone;
429
+ return `${phone.slice(0, 5)}${'*'.repeat(Math.max(phone.length - 9, 3))}${phone.slice(-4)}`;
430
+ }
431
+
432
+ module.exports = {
433
+ formatRupiah,
434
+ parseRupiah,
435
+ normalizePhoneID,
436
+ isValidPhoneID,
437
+ formatPhoneID,
438
+ getProviderID,
439
+ slugifyID,
440
+ formatTanggalID,
441
+ relativeTimeID,
442
+ createUniqueAmount,
443
+ isValidEmail,
444
+ cleanText,
445
+ titleCaseID,
446
+ maskEmail,
447
+ maskPhoneID,
448
+ constants: {
449
+ MONTHS_LONG,
450
+ MONTHS_SHORT,
451
+ DAYS_LONG,
452
+ PASARAN,
453
+ PROVIDER_PREFIXES
454
+ }
455
+ };