@indodev/toolkit 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  </div>
11
11
 
12
- TypeScript utilities for Indonesian data. Handles Rupiah formatting, terbilang, NIK validation, phone normalization, and text rules that generic libraries don't cover.
12
+ TypeScript utilities for Indonesian data. Handles Rupiah formatting, terbilang, NIK validation, phone normalization, date formatting, and text rules that generic libraries don't cover.
13
13
 
14
14
  ## Install
15
15
 
@@ -64,12 +64,27 @@ toTitleCase('pt bank central asia tbk'); // 'PT Bank Central Asia Tbk'
64
64
  slugify('Pria & Wanita'); // 'pria-dan-wanita'
65
65
  ```
66
66
 
67
+ Format dates with Indonesian locale:
68
+
69
+ ```typescript
70
+ import {
71
+ formatDate,
72
+ parseDate,
73
+ toRelativeTime,
74
+ } from '@indodev/toolkit/datetime';
75
+
76
+ formatDate(new Date('2026-01-02'), 'long'); // '2 Januari 2026'
77
+ parseDate('02-01-2026'); // Date(2026, 0, 2)
78
+ toRelativeTime(new Date(Date.now() - 3600000)); // '1 jam yang lalu'
79
+ ```
80
+
67
81
  ## Modules
68
82
 
69
83
  | Module | Description |
70
84
  | --------------------------------------------------------------- | -------------------------------------------------------------- |
71
- | [Currency](https://toolkit.adamm.cloud/docs/financial/currency) | Format Rupiah, terbilang, split amounts, percentages |
72
- | [Text](https://toolkit.adamm.cloud/docs/text-utils/text) | Title case, slugs, abbreviations, case conversion, masking |
85
+ | [Currency](https://toolkit.adamm.cloud/docs/utilities/currency) | Format Rupiah, terbilang, split amounts, percentages |
86
+ | [Text](https://toolkit.adamm.cloud/docs/utilities/text) | Title case, slugs, abbreviations, case conversion, masking |
87
+ | [DateTime](https://toolkit.adamm.cloud/docs/utilities/datetime) | Indonesian date formatting, relative time, age calculation |
73
88
  | [NIK](https://toolkit.adamm.cloud/docs/identity/nik) | Validate, parse, and mask Indonesian National Identity Numbers |
74
89
  | [NPWP](https://toolkit.adamm.cloud/docs/identity/npwp) | Validate and format Tax Identification Numbers |
75
90
  | [Phone](https://toolkit.adamm.cloud/docs/contact/phone) | Format, validate, and detect mobile operators |
@@ -77,6 +92,7 @@ slugify('Pria & Wanita'); // 'pria-dan-wanita'
77
92
  | [Plate](https://toolkit.adamm.cloud/docs/vehicles/plate) | Validate license plates with region detection |
78
93
  | [VIN](https://toolkit.adamm.cloud/docs/vehicles/vin) | Validate Vehicle Identification Numbers (ISO 3779) |
79
94
 
95
+
80
96
  Full docs, examples, and API reference at [toolkit.adamm.cloud](https://toolkit.adamm.cloud/docs)
81
97
 
82
98
  MIT
package/dist/index.cjs CHANGED
@@ -59,53 +59,66 @@ var REGENCIES = {
59
59
  }
60
60
  };
61
61
 
62
- // src/nik/validate.ts
63
- function validateNIK(nik) {
64
- if (!/^\d{16}$/.test(nik)) {
65
- return false;
66
- }
67
- const provinceCode = nik.substring(0, 2);
68
- if (!PROVINCES[provinceCode]) {
69
- return false;
62
+ // src/nik/utils/date.ts
63
+ function parseNIKDate(nik) {
64
+ if (nik.length !== 16) {
65
+ return null;
70
66
  }
71
67
  const yearStr = nik.substring(6, 8);
72
68
  const monthStr = nik.substring(8, 10);
73
- const dayStr = nik.substring(10, 12);
69
+ const dayEncodedStr = nik.substring(10, 12);
74
70
  const year = parseInt(yearStr, 10);
71
+ if (isNaN(year)) return null;
75
72
  const fullYear = year > 30 ? 1900 + year : 2e3 + year;
76
73
  const month = parseInt(monthStr, 10);
77
- let day = parseInt(dayStr, 10);
78
- if (day > 40) {
79
- day = day - 40;
74
+ if (isNaN(month)) return null;
75
+ const dayEncoded = parseInt(dayEncodedStr, 10);
76
+ if (isNaN(dayEncoded)) return null;
77
+ const gender = dayEncoded > 40 ? "female" : "male";
78
+ const day = dayEncoded > 40 ? dayEncoded - 40 : dayEncoded;
79
+ return { year, fullYear, month, day, gender, dayEncoded };
80
+ }
81
+ function validateNIKDateComponents(year, month, day) {
82
+ if (month < 1 || month > 12) return false;
83
+ if (day < 1 || day > 31) return false;
84
+ const testDate = new Date(year, month - 1, day);
85
+ return testDate.getFullYear() === year && testDate.getMonth() === month - 1 && testDate.getDate() === day;
86
+ }
87
+
88
+ // src/nik/validate.ts
89
+ var NIK_PATTERN = /^\d{16}$/;
90
+ function validateNIK(nik) {
91
+ if (!NIK_PATTERN.test(nik)) {
92
+ return false;
80
93
  }
81
- if (month < 1 || month > 12) {
94
+ const provinceCode = nik.substring(0, 2);
95
+ if (!PROVINCES[provinceCode]) {
82
96
  return false;
83
97
  }
84
- if (day < 1 || day > 31) {
98
+ const parsed = parseNIKDate(nik);
99
+ if (!parsed) {
85
100
  return false;
86
101
  }
87
- const testDate = new Date(fullYear, month - 1, day);
88
- if (testDate.getFullYear() !== fullYear || testDate.getMonth() !== month - 1 || testDate.getDate() !== day) {
102
+ const { fullYear, month, day } = parsed;
103
+ if (!validateNIKDateComponents(fullYear, month, day)) {
89
104
  return false;
90
105
  }
91
106
  const now = /* @__PURE__ */ new Date();
92
- if (testDate > now || testDate < new Date(1900, 0, 1)) {
107
+ if (new Date(fullYear, month - 1, day) > now || fullYear < 1900) {
93
108
  return false;
94
109
  }
95
110
  return true;
96
111
  }
97
112
 
98
113
  // src/nik/parse.ts
114
+ var NIK_PATTERN2 = /^\d{16}$/;
99
115
  function parseNIK(nik) {
100
- if (!/^\d{16}$/.test(nik)) {
116
+ if (!NIK_PATTERN2.test(nik)) {
101
117
  return null;
102
118
  }
103
119
  const provinceCode = nik.substring(0, 2);
104
120
  const regencyCode = nik.substring(2, 4);
105
121
  const districtCode = nik.substring(4, 6);
106
- const yearStr = nik.substring(6, 8);
107
- const monthStr = nik.substring(8, 10);
108
- const dayStr = nik.substring(10, 12);
109
122
  const serialNumber = nik.substring(12, 16);
110
123
  const province = PROVINCES[provinceCode];
111
124
  if (!province) {
@@ -113,21 +126,15 @@ function parseNIK(nik) {
113
126
  }
114
127
  const regencies = REGENCIES[provinceCode] || {};
115
128
  const regency = regencies[regencyCode] || "Unknown";
116
- let day = parseInt(dayStr, 10);
117
- const month = parseInt(monthStr, 10);
118
- const year = parseInt(yearStr, 10);
119
- let gender = null;
120
- if (day > 40) {
121
- gender = "female";
122
- day -= 40;
123
- } else {
124
- gender = "male";
129
+ const parsed = parseNIKDate(nik);
130
+ if (!parsed) {
131
+ return null;
125
132
  }
126
- const fullYear = year > 30 ? 1900 + year : 2e3 + year;
127
- const birthDate = new Date(fullYear, month - 1, day);
128
- if (birthDate.getFullYear() !== fullYear || birthDate.getMonth() !== month - 1 || birthDate.getDate() !== day) {
133
+ const { fullYear, month, day, gender } = parsed;
134
+ if (!validateNIKDateComponents(fullYear, month, day)) {
129
135
  return null;
130
136
  }
137
+ const birthDate = new Date(fullYear, month - 1, day);
131
138
  return {
132
139
  province: {
133
140
  code: provinceCode,
@@ -717,7 +724,7 @@ function isMobileNumber(phone) {
717
724
  let normalized;
718
725
  if (cleaned.startsWith("+62")) {
719
726
  normalized = "0" + cleaned.substring(3);
720
- } else if (cleaned.startsWith("62")) {
727
+ } else if (cleaned.startsWith("62") && !cleaned.startsWith("620")) {
721
728
  normalized = "0" + cleaned.substring(2);
722
729
  } else {
723
730
  normalized = cleaned;
@@ -731,19 +738,70 @@ function isLandlineNumber(phone) {
731
738
  return !isMobileNumber(phone);
732
739
  }
733
740
 
741
+ // src/phone/utils.ts
742
+ function normalizePhoneNumber(phone) {
743
+ if (!phone || typeof phone !== "string") {
744
+ return "";
745
+ }
746
+ if (phone.startsWith("+62")) {
747
+ return "0" + phone.substring(3);
748
+ }
749
+ if (phone.startsWith("62") && !phone.startsWith("620")) {
750
+ return "0" + phone.substring(2);
751
+ }
752
+ if (phone.startsWith("0")) {
753
+ return phone;
754
+ }
755
+ return "";
756
+ }
757
+ function normalizeToNational(phone) {
758
+ if (phone.startsWith("+62")) {
759
+ return "0" + phone.substring(3);
760
+ }
761
+ if (phone.startsWith("62") && !phone.startsWith("620")) {
762
+ return "0" + phone.substring(2);
763
+ }
764
+ if (phone.startsWith("0")) {
765
+ return phone;
766
+ }
767
+ return "";
768
+ }
769
+ function getLandlineRegion(phone) {
770
+ if (!phone || typeof phone !== "string") {
771
+ return null;
772
+ }
773
+ const cleaned = phone.replace(/[^\d+]/g, "");
774
+ const normalized = normalizeToNational(cleaned);
775
+ if (!normalized || !normalized.startsWith("0")) {
776
+ return null;
777
+ }
778
+ if (normalized.startsWith("08")) {
779
+ return null;
780
+ }
781
+ const areaCode4 = normalized.substring(0, 4);
782
+ if (AREA_CODES[areaCode4]) {
783
+ return AREA_CODES[areaCode4];
784
+ }
785
+ const areaCode3 = normalized.substring(0, 3);
786
+ if (AREA_CODES[areaCode3]) {
787
+ return AREA_CODES[areaCode3];
788
+ }
789
+ const areaCode2 = normalized.substring(0, 2);
790
+ if (AREA_CODES[areaCode2]) {
791
+ return AREA_CODES[areaCode2];
792
+ }
793
+ return null;
794
+ }
795
+
734
796
  // src/phone/format.ts
735
797
  function formatPhoneNumber(phone, format = "national") {
736
798
  if (!validatePhoneNumber(phone)) {
737
799
  return phone;
738
800
  }
739
801
  const cleaned = cleanPhoneNumber(phone);
740
- let normalized;
741
- if (cleaned.startsWith("+62")) {
742
- normalized = "0" + cleaned.substring(3);
743
- } else if (cleaned.startsWith("62") && !cleaned.startsWith("620")) {
744
- normalized = "0" + cleaned.substring(2);
745
- } else {
746
- normalized = cleaned;
802
+ const normalized = normalizePhoneNumber(cleaned);
803
+ if (!normalized) {
804
+ return phone;
747
805
  }
748
806
  switch (format) {
749
807
  case "international":
@@ -762,7 +820,7 @@ function toInternational(phone) {
762
820
  if (!cleaned) {
763
821
  return phone;
764
822
  }
765
- const normalized = normalizeToNational(cleaned);
823
+ const normalized = normalizePhoneNumber(cleaned);
766
824
  if (!normalized) {
767
825
  return phone;
768
826
  }
@@ -788,7 +846,7 @@ function toNational(phone) {
788
846
  if (!cleaned) {
789
847
  return phone;
790
848
  }
791
- const normalized = normalizeToNational(cleaned);
849
+ const normalized = normalizePhoneNumber(cleaned);
792
850
  if (!normalized) {
793
851
  return phone;
794
852
  }
@@ -813,7 +871,7 @@ function toE164(phone) {
813
871
  if (!cleaned) {
814
872
  return phone;
815
873
  }
816
- const normalized = normalizeToNational(cleaned);
874
+ const normalized = normalizePhoneNumber(cleaned);
817
875
  if (!normalized) {
818
876
  return phone;
819
877
  }
@@ -825,16 +883,6 @@ function cleanPhoneNumber(phone) {
825
883
  }
826
884
  return phone.replace(/[^\d+]/g, "");
827
885
  }
828
- function normalizeToNational(phone) {
829
- if (phone.startsWith("+62")) {
830
- return "0" + phone.substring(3);
831
- } else if (phone.startsWith("62") && !phone.startsWith("620")) {
832
- return "0" + phone.substring(2);
833
- } else if (phone.startsWith("0")) {
834
- return phone;
835
- }
836
- return "";
837
- }
838
886
  function getAreaCodeLength(normalized) {
839
887
  const fourDigitCode = normalized.substring(0, 5);
840
888
  if (AREA_CODES[fourDigitCode]) {
@@ -860,7 +908,7 @@ function maskPhoneNumber(phone, options = {}) {
860
908
  if (isInternational) {
861
909
  toMask = cleaned;
862
910
  } else {
863
- const normalized = normalizeToNational(cleaned);
911
+ const normalized = normalizePhoneNumber(cleaned);
864
912
  toMask = normalized || cleaned;
865
913
  }
866
914
  if (toMask.length < 4) {
@@ -929,7 +977,7 @@ function parsePhoneNumber(phone) {
929
977
  return null;
930
978
  }
931
979
  const cleaned = cleanPhoneNumber(phone);
932
- const normalized = normalizeToNational2(cleaned);
980
+ const normalized = normalizePhoneNumber(cleaned);
933
981
  if (!normalized) {
934
982
  return null;
935
983
  }
@@ -942,7 +990,7 @@ function parsePhoneNumber(phone) {
942
990
  if (isMobile) {
943
991
  operator = getOperator(normalized);
944
992
  } else {
945
- region = getRegion(normalized);
993
+ region = getLandlineRegion(normalized);
946
994
  }
947
995
  return {
948
996
  countryCode,
@@ -964,7 +1012,7 @@ function getOperator(phone) {
964
1012
  return null;
965
1013
  }
966
1014
  const cleaned = cleanPhoneNumber(phone);
967
- const normalized = normalizeToNational2(cleaned);
1015
+ const normalized = normalizePhoneNumber(cleaned);
968
1016
  if (!normalized || normalized.length < 4) {
969
1017
  return null;
970
1018
  }
@@ -978,30 +1026,6 @@ function isProvider(phone, providerName) {
978
1026
  }
979
1027
  return operator.toLowerCase() === providerName.toLowerCase();
980
1028
  }
981
- function getRegion(phone) {
982
- if (!phone.startsWith("0")) {
983
- return null;
984
- }
985
- const areaCode4 = phone.substring(0, 4);
986
- if (AREA_CODES[areaCode4]) {
987
- return AREA_CODES[areaCode4];
988
- }
989
- const areaCode3 = phone.substring(0, 3);
990
- if (AREA_CODES[areaCode3]) {
991
- return AREA_CODES[areaCode3];
992
- }
993
- return null;
994
- }
995
- function normalizeToNational2(phone) {
996
- if (phone.startsWith("+62")) {
997
- return "0" + phone.substring(3);
998
- } else if (phone.startsWith("62")) {
999
- return "0" + phone.substring(2);
1000
- } else if (phone.startsWith("0")) {
1001
- return phone;
1002
- }
1003
- return "";
1004
- }
1005
1029
 
1006
1030
  // src/npwp/validate.ts
1007
1031
  function validateNPWP(npwp) {