@jarenjs/core 0.9.2 → 0.34.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.
Files changed (121) hide show
  1. package/ARCHITECTURE.md +229 -26
  2. package/README.md +64 -10
  3. package/dist/types/array.d.ts +13 -0
  4. package/dist/types/cache.d.ts +166 -0
  5. package/dist/types/chunk.d.ts +102 -0
  6. package/dist/types/color.d.ts +64 -0
  7. package/dist/types/convert/convert.d.ts +33 -0
  8. package/dist/types/convert/currency.d.ts +47 -0
  9. package/dist/types/convert/index.d.ts +9 -0
  10. package/dist/types/convert/registry.d.ts +55 -0
  11. package/dist/types/dates/civil.d.ts +146 -0
  12. package/dist/types/dates/duration.d.ts +83 -0
  13. package/dist/types/dates/format.d.ts +59 -0
  14. package/dist/types/dates/index.d.ts +4 -0
  15. package/dist/types/{dates.d.ts → dates/rfc3339.d.ts} +57 -3
  16. package/dist/types/errors.d.ts +77 -0
  17. package/dist/types/finance/amortization.d.ts +51 -0
  18. package/dist/types/finance/bond.d.ts +45 -0
  19. package/dist/types/finance/cashflow.d.ts +44 -0
  20. package/dist/types/finance/depreciation.d.ts +26 -0
  21. package/dist/types/finance/index.d.ts +16 -0
  22. package/dist/types/finance/indicators.d.ts +76 -0
  23. package/dist/types/finance/interest.d.ts +44 -0
  24. package/dist/types/finance/returns.d.ts +43 -0
  25. package/dist/types/finance/tvm.d.ts +50 -0
  26. package/dist/types/geo/angle.d.ts +4 -0
  27. package/dist/types/geo/bbox.d.ts +32 -0
  28. package/dist/types/geo/distance.d.ts +67 -0
  29. package/dist/types/geo/geohash.d.ts +63 -0
  30. package/dist/types/geo/geojson.d.ts +108 -0
  31. package/dist/types/geo/index-tree.d.ts +52 -0
  32. package/dist/types/geo/index.d.ts +11 -0
  33. package/dist/types/geo/mercator.d.ts +57 -0
  34. package/dist/types/geo/predicates.d.ts +44 -0
  35. package/dist/types/geo/ring.d.ts +80 -0
  36. package/dist/types/geo/simplify.d.ts +31 -0
  37. package/dist/types/geo/valid.d.ts +24 -0
  38. package/dist/types/geo/wkt.d.ts +16 -0
  39. package/dist/types/math/float64.d.ts +126 -3
  40. package/dist/types/math/format.d.ts +58 -0
  41. package/dist/types/math/index.d.ts +5 -0
  42. package/dist/types/math/mat4.d.ts +60 -0
  43. package/dist/types/math/project.d.ts +54 -0
  44. package/dist/types/math/solve.d.ts +67 -0
  45. package/dist/types/math/word.d.ts +82 -0
  46. package/dist/types/message.d.ts +52 -0
  47. package/dist/types/object.d.ts +151 -0
  48. package/dist/types/scan.d.ts +30 -0
  49. package/dist/types/schema.d.ts +24 -0
  50. package/dist/types/string.d.ts +96 -0
  51. package/dist/types/text/base64.d.ts +6 -4
  52. package/dist/types/text/email.d.ts +0 -1
  53. package/dist/types/text/host.d.ts +39 -8
  54. package/dist/types/text/i18n.d.ts +13 -1
  55. package/dist/types/text/index.d.ts +1 -0
  56. package/dist/types/text/misc.d.ts +15 -1
  57. package/dist/types/text/punycode.d.ts +45 -85
  58. package/docs/CONVERT.md +45 -0
  59. package/docs/DATES.md +78 -0
  60. package/docs/FINANCE.md +59 -0
  61. package/docs/GEO.md +114 -0
  62. package/docs/MATH.md +75 -0
  63. package/package.json +57 -4
  64. package/src/array.js +16 -0
  65. package/src/cache.js +206 -0
  66. package/src/chunk.js +159 -0
  67. package/src/color.js +125 -0
  68. package/src/convert/convert.js +59 -0
  69. package/src/convert/currency.js +74 -0
  70. package/src/convert/index.js +11 -0
  71. package/src/convert/registry.js +213 -0
  72. package/src/dates/civil.js +360 -0
  73. package/src/dates/duration.js +225 -0
  74. package/src/dates/format.js +238 -0
  75. package/src/dates/index.js +30 -0
  76. package/src/dates/rfc3339.js +621 -0
  77. package/src/errors.js +102 -0
  78. package/src/finance/amortization.js +50 -0
  79. package/src/finance/bond.js +100 -0
  80. package/src/finance/cashflow.js +122 -0
  81. package/src/finance/depreciation.js +54 -0
  82. package/src/finance/index.js +27 -0
  83. package/src/finance/indicators.js +207 -0
  84. package/src/finance/interest.js +63 -0
  85. package/src/finance/returns.js +90 -0
  86. package/src/finance/tvm.js +94 -0
  87. package/src/function.js +0 -2
  88. package/src/geo/angle.js +12 -0
  89. package/src/geo/bbox.js +80 -0
  90. package/src/geo/distance.js +155 -0
  91. package/src/geo/geohash.js +224 -0
  92. package/src/geo/geojson.js +361 -0
  93. package/src/geo/index-tree.js +294 -0
  94. package/src/geo/index.js +52 -0
  95. package/src/geo/mercator.js +124 -0
  96. package/src/geo/predicates.js +313 -0
  97. package/src/geo/ring.js +182 -0
  98. package/src/geo/simplify.js +130 -0
  99. package/src/geo/valid.js +142 -0
  100. package/src/geo/wkt.js +262 -0
  101. package/src/math/float64.js +228 -11
  102. package/src/math/format.js +157 -0
  103. package/src/math/index.js +5 -0
  104. package/src/math/mat4.js +131 -0
  105. package/src/math/project.js +49 -0
  106. package/src/math/solve.js +112 -0
  107. package/src/math/word.js +177 -0
  108. package/src/message.js +119 -0
  109. package/src/object.js +329 -0
  110. package/src/scan.js +42 -0
  111. package/src/schema.js +37 -0
  112. package/src/string.js +189 -0
  113. package/src/text/base64.js +15 -52
  114. package/src/text/email.js +0 -5
  115. package/src/text/host.js +515 -140
  116. package/src/text/i18n.js +13 -6
  117. package/src/text/identifiers.js +1 -1
  118. package/src/text/index.js +1 -0
  119. package/src/text/misc.js +81 -5
  120. package/src/text/punycode.js +235 -323
  121. package/src/dates.js +0 -371
@@ -0,0 +1,621 @@
1
+ //@ts-check
2
+
3
+ import {
4
+ isStringType,
5
+ isObjectOfClass,
6
+ } from '../index.js';
7
+ // the calendar rules live in civil.js, which owns the day tables
8
+ import { isLeapYear, daysInMonth } from './civil.js';
9
+
10
+ export { isLeapYear };
11
+
12
+ //#region Dates Constants
13
+ export const CONST_TICKS_SECOND = 1000;
14
+ export const CONST_TICKS_HOUR = CONST_TICKS_SECOND * 60 * 60;
15
+ export const CONST_TICKS_DAY = CONST_TICKS_HOUR * 24;
16
+
17
+ export const CONST_TIME_INSERTDATE = '1970-01-01T';
18
+ export const CONST_DATE_APPENDTIME = 'T00:00:00Z';
19
+
20
+ export const CONST_RFC3339_DAYS = Object.freeze(
21
+ [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
22
+ );
23
+
24
+ //#endregion
25
+
26
+ //#region Dates Compare
27
+ export function isDateType(data) {
28
+ return isObjectOfClass(data, Date);
29
+ }
30
+
31
+ export function isDateishType(data) {
32
+ return isDateType(data)
33
+ || !Number.isNaN(Date.parse(data));
34
+ }
35
+
36
+ export function isDateOnlyInRange(year = 0, month = 0, day = 0) {
37
+ return month >= 1
38
+ && month <= 12
39
+ && day >= 1
40
+ && day <= daysInMonth(year, month);
41
+ }
42
+
43
+ /**
44
+ * Reads two ASCII digits at index i as a number, or -1 when either
45
+ * character is not an ASCII digit (out-of-range indexes read as NaN and
46
+ * also yield -1).
47
+ * @param {string} str - The string to read from
48
+ * @param {number} i - The index of the first digit
49
+ * @returns {number} The two-digit value, or -1
50
+ */
51
+ function getTwoDigits(str, i) {
52
+ const a = str.charCodeAt(i) - 48;
53
+ const b = str.charCodeAt(i + 1) - 48;
54
+ return (a >= 0 && a <= 9 && b >= 0 && b <= 9)
55
+ ? a * 10 + b
56
+ : -1;
57
+ }
58
+
59
+ /**
60
+ * Validates the region [from, to) of str as an RFC 3339 full-date
61
+ * (YYYY-MM-DD, with an optional trailing z/Z) without allocating.
62
+ * @param {string} str - The string containing the date
63
+ * @param {number} from - Start of the region (inclusive)
64
+ * @param {number} to - End of the region (exclusive)
65
+ * @returns {boolean} True when the region is a valid full-date
66
+ */
67
+ function isDateOnlyRegion(str, from, to) {
68
+ let end = to;
69
+ const zc = str.charCodeAt(end - 1);
70
+ if (zc === 122 || zc === 90) end--; // optional trailing z/Z
71
+ if (end - from !== 10) return false;
72
+
73
+ const y1 = getTwoDigits(str, from);
74
+ const y2 = getTwoDigits(str, from + 2);
75
+ if (y1 < 0 || y2 < 0) return false;
76
+ if (str.charCodeAt(from + 4) !== 45) return false; // '-'
77
+ const m = getTwoDigits(str, from + 5);
78
+ if (m < 0) return false;
79
+ if (str.charCodeAt(from + 7) !== 45) return false; // '-'
80
+ const d = getTwoDigits(str, from + 8);
81
+ if (d < 0) return false;
82
+ return isDateOnlyInRange(y1 * 100 + y2, m, d);
83
+ }
84
+
85
+ export function isDateOnlyRFC3339(str) {
86
+ return isStringType(str)
87
+ && isDateOnlyRegion(str, 0, str.length);
88
+ }
89
+
90
+ export function isTimeOnlyInRange(hrs = 0, min = 0, sec = 0, tzh = 0, tzm = 0, tzSign = 1) {
91
+ // Validate timezone offset range
92
+ if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
93
+ return false;
94
+
95
+ // For leap seconds (sec === 60), we need to check if the corresponding UTC time
96
+ // is 23:59:60. A leap second is only valid at the very end of a UTC day.
97
+ if (sec === 60) {
98
+ // Calculate what the UTC time would be
99
+ // UTC = local - offset (if offset is positive, local is ahead of UTC)
100
+ // So: UTC = local + (tzSign * tzh) hours + (tzSign * tzm) minutes
101
+ let utcHrs = hrs - (tzSign * tzh);
102
+ let utcMin = min - (tzSign * tzm);
103
+
104
+ // Handle wrap-around
105
+ while (utcMin < 0) {
106
+ utcMin += 60;
107
+ utcHrs -= 1;
108
+ }
109
+ while (utcMin >= 60) {
110
+ utcMin -= 60;
111
+ utcHrs += 1;
112
+ }
113
+ while (utcHrs < 0) {
114
+ utcHrs += 24;
115
+ }
116
+ utcHrs = utcHrs % 24;
117
+
118
+ // Leap second is only valid at 23:59:60 UTC
119
+ return utcHrs === 23 && utcMin === 59 && sec === 60;
120
+ }
121
+
122
+ // Normal time validation (sec 0-59)
123
+ return hrs >= 0 && hrs <= 23
124
+ && min >= 0 && min <= 59
125
+ && sec >= 0 && sec <= 59;
126
+ }
127
+
128
+ /**
129
+ * Validates the tail of str starting at index from as an RFC 3339
130
+ * full-time (HH:MM:SS with an optional 1-6 digit fraction and a required
131
+ * z/Z or +HH:MM/-HH:MM offset) without allocating.
132
+ * @param {string} str - The string containing the time
133
+ * @param {number} from - Start of the time (inclusive; runs to the end)
134
+ * @returns {boolean} True when the tail is a valid full-time
135
+ */
136
+ function isTimeOnlyRegion(str, from) {
137
+ const len = str.length;
138
+ const h = getTwoDigits(str, from);
139
+ if (h < 0 || str.charCodeAt(from + 2) !== 58) return false; // ':'
140
+ const m = getTwoDigits(str, from + 3);
141
+ if (m < 0 || str.charCodeAt(from + 5) !== 58) return false; // ':'
142
+ const s = getTwoDigits(str, from + 6);
143
+ if (s < 0) return false;
144
+
145
+ let i = from + 8;
146
+ if (str.charCodeAt(i) === 46) { // '.' starts a 1-6 digit fraction
147
+ const start = ++i;
148
+ while (i < len) {
149
+ const c = str.charCodeAt(i);
150
+ if (c >= 48 && c <= 57) i++;
151
+ else break;
152
+ }
153
+ const digits = i - start;
154
+ if (digits < 1 || digits > 6) return false;
155
+ }
156
+
157
+ const c = str.charCodeAt(i);
158
+ if (c === 122 || c === 90) { // z/Z: UTC, must end the string
159
+ return i + 1 === len
160
+ && isTimeOnlyInRange(h, m, s, 0, 0, 1);
161
+ }
162
+
163
+ if (c !== 43 && c !== 45) return false; // '+'/'-'
164
+ if (i + 6 !== len) return false;
165
+ const th = getTwoDigits(str, i + 1);
166
+ if (th < 0 || str.charCodeAt(i + 3) !== 58) return false; // ':'
167
+ const tm = getTwoDigits(str, i + 4);
168
+ if (tm < 0) return false;
169
+ // + means local time is ahead of UTC, so we subtract to get UTC
170
+ // - means local time is behind UTC, so we add to get UTC
171
+ return isTimeOnlyInRange(h, m, s, th, tm, c === 45 ? -1 : 1);
172
+ }
173
+
174
+ export function isTimeOnlyRFC3339(str) {
175
+ return isStringType(str)
176
+ && isTimeOnlyRegion(str, 0);
177
+ }
178
+
179
+ /**
180
+ * Whether the character code separates the date and time parts of a
181
+ * date-time: 't'/'T' or whitespace (the set matched by the \s class).
182
+ * @param {number} c - The character code
183
+ * @returns {boolean} True for a date/time separator
184
+ */
185
+ function isDateTimeSeparator(c) {
186
+ if (c === 116 || c === 84 || c === 32) return true; // t T space
187
+ if (c >= 9 && c <= 13) return true; // \t \n \v \f \r
188
+ return c === 0x00A0 || c === 0x1680
189
+ || (c >= 0x2000 && c <= 0x200A)
190
+ || c === 0x2028 || c === 0x2029 || c === 0x202F
191
+ || c === 0x205F || c === 0x3000 || c === 0xFEFF;
192
+ }
193
+
194
+ export function isDateTimeRFC3339(str) {
195
+ // http://tools.ietf.org/html/rfc3339#section-5.6
196
+ if (!isStringType(str)) return false;
197
+ // Exactly one separator splits the full-date from the full-time.
198
+ const len = str.length;
199
+ let sep = -1;
200
+ for (let i = 0; i < len; ++i) {
201
+ if (isDateTimeSeparator(str.charCodeAt(i))) {
202
+ if (sep !== -1) return false;
203
+ sep = i;
204
+ }
205
+ }
206
+ return sep !== -1
207
+ && isDateOnlyRegion(str, 0, sep)
208
+ && isTimeOnlyRegion(str, sep + 1);
209
+ }
210
+
211
+ //#endregion
212
+
213
+ //#region Dates Getters
214
+ export function getDateTypeOfDateOnlyRFC3339(str, def = undefined) {
215
+ return isDateOnlyRFC3339(str)
216
+ ? new Date(Date.parse(str))
217
+ : def;
218
+ }
219
+
220
+ export function getDateTypeOfTimeOnlyRFC3339(str, def = undefined) {
221
+ return isTimeOnlyRFC3339(str)
222
+ ? new Date(Date.parse(CONST_TIME_INSERTDATE + str))
223
+ : def;
224
+ }
225
+
226
+ export function getDateTypeOfDateTimeRFC3339(str, def = undefined) {
227
+ return isDateTimeRFC3339(str)
228
+ ? new Date(Date.parse(str))
229
+ : def;
230
+ }
231
+
232
+ // The epoch twins of the three getters above: the same validity check
233
+ // and the same engine parse, but the result stays a number, so a
234
+ // caller comparing against precomputed epoch bounds allocates no Date.
235
+ // A string the grammar accepts but the engine cannot parse (a leap
236
+ // second's `:60`) reads as NaN, exactly as its Date twin reads as an
237
+ // invalid Date - every comparison against it is false.
238
+
239
+ export function getEpochOfDateOnlyRFC3339(str, def = undefined) {
240
+ return isDateOnlyRFC3339(str)
241
+ ? Date.parse(str)
242
+ : def;
243
+ }
244
+
245
+ export function getEpochOfTimeOnlyRFC3339(str, def = undefined) {
246
+ return isTimeOnlyRFC3339(str)
247
+ ? Date.parse(CONST_TIME_INSERTDATE + str)
248
+ : def;
249
+ }
250
+
251
+ export function getEpochOfDateTimeRFC3339(str, def = undefined) {
252
+ return isDateTimeRFC3339(str)
253
+ ? Date.parse(str)
254
+ : def;
255
+ }
256
+
257
+ /**
258
+ * Reads the full-date at index from as {year, month, day}. The region
259
+ * must already have passed isDateOnlyRegion.
260
+ * @param {string} str - The string containing the date
261
+ * @param {number} from - Index of the first digit
262
+ * @returns {{ year: number, month: number, day: number }}
263
+ */
264
+ function readDateParts(str, from) {
265
+ return {
266
+ year: getTwoDigits(str, from) * 100 + getTwoDigits(str, from + 2),
267
+ month: getTwoDigits(str, from + 5),
268
+ day: getTwoDigits(str, from + 8),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Reads the full-time at index from as {hours, minutes, seconds, offset}.
274
+ * `seconds` carries the fraction, `offset` is minutes east of UTC. The
275
+ * region must already have passed isTimeOnlyRegion.
276
+ * @param {string} str - The string containing the time
277
+ * @param {number} from - Index of the first digit
278
+ * @returns {{ hours: number, minutes: number, seconds: number, offset: number }}
279
+ */
280
+ function readTimeParts(str, from) {
281
+ const hours = getTwoDigits(str, from);
282
+ const minutes = getTwoDigits(str, from + 3);
283
+ let seconds = getTwoDigits(str, from + 6);
284
+ let i = from + 8;
285
+ if (str.charCodeAt(i) === 46) { // '.' fraction
286
+ const start = ++i;
287
+ while (i < str.length) {
288
+ const c = str.charCodeAt(i);
289
+ if (c < 48 || c > 57) break;
290
+ i++;
291
+ }
292
+ seconds += Number(str.slice(start - 1, i)); // '.ddd' as a fraction
293
+ }
294
+ const c = str.charCodeAt(i);
295
+ let offset = 0;
296
+ if (c === 43 || c === 45) { // '+' | '-'
297
+ const magnitude = getTwoDigits(str, i + 1) * 60 + getTwoDigits(str, i + 4);
298
+ offset = c === 45 ? -magnitude : magnitude;
299
+ }
300
+ return { hours, minutes, seconds, offset };
301
+ }
302
+
303
+ /**
304
+ * Decompose an RFC 3339 date, time, or date-time string into its lexical
305
+ * components, without allocating a `Date` and without shifting anything
306
+ * to UTC — the components are the ones the string spells out, which is
307
+ * what a query grouping by year or month asks for.
308
+ *
309
+ * A missing half reads as -1: a full-date has no `hours`, a full-time no
310
+ * `year`. `offset` is minutes east of UTC, `null` only for a bare
311
+ * full-date (which RFC 3339 leaves offset-less).
312
+ *
313
+ * @param {any} str - The value to decompose
314
+ * @returns {{ year: number, month: number, day: number, hours: number,
315
+ * minutes: number, seconds: number, offset: number | null } | null}
316
+ * the components, or null when str is not an RFC 3339 value
317
+ * @example
318
+ * parseRFC3339Parts('2026-07-27T14:30:05.5+02:00');
319
+ * // { year: 2026, month: 7, day: 27, hours: 14, minutes: 30,
320
+ * // seconds: 5.5, offset: 120 }
321
+ */
322
+ export function parseRFC3339Parts(str) {
323
+ if (!isStringType(str))
324
+ return null;
325
+ if (isDateTimeRFC3339(str)) {
326
+ let sep = -1;
327
+ for (let i = 0; i < str.length; ++i) {
328
+ if (isDateTimeSeparator(str.charCodeAt(i))) {
329
+ sep = i;
330
+ break;
331
+ }
332
+ }
333
+ return { ...readDateParts(str, 0), ...readTimeParts(str, sep + 1) };
334
+ }
335
+ if (isDateOnlyRFC3339(str)) {
336
+ const zc = str.charCodeAt(str.length - 1);
337
+ const zulu = zc === 122 || zc === 90;
338
+ return {
339
+ ...readDateParts(str, 0),
340
+ hours: -1, minutes: -1, seconds: -1,
341
+ offset: zulu ? 0 : null,
342
+ };
343
+ }
344
+ if (isTimeOnlyRFC3339(str)) {
345
+ return { year: -1, month: -1, day: -1, ...readTimeParts(str, 0) };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ /**
351
+ * Milliseconds since 1970-01-01T00:00:00Z for RFC 3339 components that
352
+ * carry a date. Components without a date (a full-time) return NaN, as
353
+ * do out-of-range instants.
354
+ *
355
+ * A bare full-date has no offset and is read as UTC midnight. Note JS
356
+ * has no leap seconds: a `:60` second rolls into the following minute.
357
+ *
358
+ * @param {{ year: number, month: number, day: number, hours: number,
359
+ * minutes: number, seconds: number, offset: number | null }} parts -
360
+ * components from {@link parseRFC3339Parts}
361
+ * @returns {number} milliseconds since the epoch, or NaN
362
+ */
363
+ export function epochOfRFC3339Parts(parts) {
364
+ if (parts.year < 0)
365
+ return NaN;
366
+ const seconds = parts.seconds < 0 ? 0 : parts.seconds;
367
+ const whole = Math.floor(seconds);
368
+ const ms = Date.UTC(
369
+ parts.year, parts.month - 1, parts.day,
370
+ parts.hours < 0 ? 0 : parts.hours,
371
+ parts.minutes < 0 ? 0 : parts.minutes,
372
+ whole, Math.round((seconds - whole) * 1000));
373
+ if (ms !== ms)
374
+ return NaN;
375
+ // Date.UTC maps years 0-99 into the 1900s; restore the real year
376
+ const utc = new Date(ms);
377
+ if (parts.year >= 0 && parts.year < 100)
378
+ utc.setUTCFullYear(parts.year);
379
+ return utc.getTime() - (parts.offset === null ? 0 : parts.offset) * 60000;
380
+ }
381
+ //#endregion
382
+
383
+ //#region Duration Validation (RFC 3339)
384
+ // Duration format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W
385
+ // Examples: P1Y2M3DT4H5M6S, P1W, PT1H, P1Y
386
+ // https://tools.ietf.org/html/rfc3339#appendix-A
387
+
388
+ /**
389
+ * Validates a duration string per RFC 3339.
390
+ * Duration format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W
391
+ *
392
+ * @param {string} str - The duration string to validate
393
+ * @returns {boolean} - True if the string is a valid duration
394
+ * @example
395
+ * isValidDuration('P1Y2M3DT4H5M6S'); // true (1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds)
396
+ * isValidDuration('P1W'); // true (1 week)
397
+ * isValidDuration('PT1H'); // true (1 hour)
398
+ * isValidDuration('P1Y'); // true (1 year)
399
+ * isValidDuration('P'); // false (empty duration)
400
+ * isValidDuration('1Y'); // false (missing P)
401
+ */
402
+ export function isValidDuration(str) {
403
+ if (!isStringType(str))
404
+ return false;
405
+
406
+ // Must start with P
407
+ if (!str.startsWith('P'))
408
+ return false;
409
+
410
+ // Cannot be just "P"
411
+ if (str.length < 2)
412
+ return false;
413
+
414
+ // Parse and validate components
415
+ // Remove the 'P' prefix
416
+ const rest = str.slice(1);
417
+
418
+ // Check for week format: P[n]W (cannot be combined with other components)
419
+ if (rest.endsWith('W')) {
420
+ const weekPart = rest.slice(0, -1);
421
+ return weekPart.length > 0 && /^\d+$/.test(weekPart);
422
+ }
423
+
424
+ // Split into date and time parts
425
+ const tIndex = rest.indexOf('T');
426
+ const datePart = tIndex >= 0 ? rest.slice(0, tIndex) : rest;
427
+ const timePart = tIndex >= 0 ? rest.slice(tIndex + 1) : '';
428
+
429
+ // Must have at least one component
430
+ if (!datePart && !timePart)
431
+ return false;
432
+
433
+ // If there's a T, there must be at least one time component
434
+ if (tIndex >= 0 && !timePart)
435
+ return false;
436
+
437
+ // Validate date part components (Y, M, D)
438
+ if (datePart) {
439
+ // Must match pattern: optional number+Y, optional number+M, optional number+D
440
+ // in that order, at least one must be present
441
+ const dateRegex = /^(\d+Y)?(\d+M)?(\d+D)?$/;
442
+ if (!dateRegex.test(datePart))
443
+ return false;
444
+ // Must have at least one component
445
+ if (!/\d+[YMD]/.test(datePart))
446
+ return false;
447
+ }
448
+
449
+ // Validate time part components (H, M, S)
450
+ if (timePart) {
451
+ const timeRegex = /^(\d+H)?(\d+M)?(\d+(?:\.\d+)?S)?$/;
452
+ if (!timeRegex.test(timePart))
453
+ return false;
454
+ // Must have at least one component
455
+ if (!/\d+[HMS]/.test(timePart))
456
+ return false;
457
+ }
458
+
459
+ return true;
460
+ }
461
+ //#endregion
462
+
463
+ //#region ISO Date-Time and ISO Time (with optional timezone)
464
+ // ISO 8601 date-time with optional timezone (like 2024-01-15T12:30:00)
465
+ // ISO 8601 time with optional timezone (like 12:30:00)
466
+
467
+ // Regex for iso-date-time: allows with or without timezone
468
+ const CONST_ISO_REGEX_DATETIME = /^(\d{4})-([0-1]\d)-([0-3]\d)[T\s](\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:(Z)|([+-])(\d{2}):(\d{2}))?$/i;
469
+
470
+ // Regex for iso-time: allows with or without timezone
471
+ const CONST_ISO_REGEX_TIME = /^(\d{2}):(\d{2}):(\d{2})(\.\d{1,6})?(?:(Z)|([+-])(\d{2}):(\d{2}))?$/i;
472
+
473
+ /**
474
+ * Validates an ISO 8601 date-time string with optional timezone.
475
+ * Unlike RFC 3339, the timezone is optional.
476
+ *
477
+ * @param {string} str - The date-time string to validate
478
+ * @returns {boolean} - True if the string is a valid ISO date-time
479
+ * @example
480
+ * isValidISODateTime('2024-01-15T12:30:00Z'); // true
481
+ * isValidISODateTime('2024-01-15T12:30:00+01:00'); // true
482
+ * isValidISODateTime('2024-01-15T12:30:00'); // true (no timezone)
483
+ * isValidISODateTime('2024-01-15 12:30:00'); // true (space separator)
484
+ * isValidISODateTime('2024-13-15T12:30:00'); // false (invalid month)
485
+ */
486
+ export function isValidISODateTime(str) {
487
+ if (!isStringType(str))
488
+ return false;
489
+
490
+ const r = str.match(CONST_ISO_REGEX_DATETIME);
491
+ if (r == null)
492
+ return false;
493
+
494
+ const y = parseInt(r[1], 10) | 0;
495
+ const m = parseInt(r[2], 10) | 0;
496
+ const d = parseInt(r[3], 10) | 0;
497
+ const h = parseInt(r[4], 10) | 0;
498
+ const min = parseInt(r[5], 10) | 0;
499
+ const s = parseInt(r[6], 10) | 0;
500
+
501
+ // Validate date portion
502
+ if (!isDateOnlyInRange(y, m, d))
503
+ return false;
504
+
505
+ // Validate time portion (no leap seconds for ISO date-time without explicit timezone)
506
+ if (h < 0 || h > 23 || min < 0 || min > 59 || s < 0 || s > 59)
507
+ return false;
508
+
509
+ // Validate timezone if present
510
+ if (r[9] != null) {
511
+ const tzh = parseInt(r[10], 10) | 0;
512
+ const tzm = parseInt(r[11], 10) | 0;
513
+ if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
514
+ return false;
515
+ }
516
+
517
+ return true;
518
+ }
519
+
520
+ /**
521
+ * Validates an ISO 8601 time string with optional timezone.
522
+ * Unlike RFC 3339, the timezone is optional.
523
+ *
524
+ * @param {string} str - The time string to validate
525
+ * @returns {boolean} - True if the string is a valid ISO time
526
+ * @example
527
+ * isValidISOTime('12:30:00Z'); // true
528
+ * isValidISOTime('12:30:00+01:00'); // true
529
+ * isValidISOTime('12:30:00'); // true (no timezone)
530
+ * isValidISOTime('25:00:00'); // false (invalid hour)
531
+ */
532
+ export function isValidISOTime(str) {
533
+ if (!isStringType(str))
534
+ return false;
535
+
536
+ const r = str.match(CONST_ISO_REGEX_TIME);
537
+ if (r == null)
538
+ return false;
539
+
540
+ const h = parseInt(r[1], 10) | 0;
541
+ const m = parseInt(r[2], 10) | 0;
542
+ const s = parseInt(r[3], 10) | 0;
543
+
544
+ // Validate time portion (no leap seconds for ISO time without explicit timezone)
545
+ if (h < 0 || h > 23 || m < 0 || m > 59 || s < 0 || s > 59)
546
+ return false;
547
+
548
+ // Validate timezone if present
549
+ if (r[7] != null) {
550
+ const tzh = parseInt(r[8], 10) | 0;
551
+ const tzm = parseInt(r[9], 10) | 0;
552
+ if (tzh < 0 || tzh > 23 || tzm < 0 || tzm > 59)
553
+ return false;
554
+ }
555
+
556
+ return true;
557
+ }
558
+
559
+ /**
560
+ * Parses an ISO 8601 date-time string with optional timezone.
561
+ * Returns a Date object if valid, otherwise undefined.
562
+ *
563
+ * @param {string} str - The date-time string to parse
564
+ * @param {any} [def=undefined] - Default value to return if invalid
565
+ * @returns {Date|undefined} - The parsed Date or default value
566
+ */
567
+ export function getDateTypeOfISODateTime(str, def = undefined) {
568
+ if (!isValidISODateTime(str))
569
+ return def;
570
+
571
+ // If no timezone specified, treat as local time by appending Z
572
+ // (ISO 8601 without timezone is local time, but for consistency we treat as UTC)
573
+ if (!/[Z+-]\d{2}:\d{2}$/i.test(str) && !str.endsWith('Z')) {
574
+ // Try parsing as-is (Date.parse handles both formats)
575
+ const date = new Date(Date.parse(str.replace(' ', 'T')));
576
+ return isNaN(date.getTime()) ? def : date;
577
+ }
578
+
579
+ return new Date(Date.parse(str));
580
+ }
581
+
582
+ /**
583
+ * Parses an ISO 8601 time string with optional timezone.
584
+ * Returns a Date object (with 1970-01-01 as date) if valid, otherwise undefined.
585
+ *
586
+ * @param {string} str - The time string to parse
587
+ * @param {any} [def=undefined] - Default value to return if invalid
588
+ * @returns {Date|undefined} - The parsed Date or default value
589
+ */
590
+ export function getDateTypeOfISOTime(str, def = undefined) {
591
+ if (!isValidISOTime(str))
592
+ return def;
593
+
594
+ // Prepend a dummy date for parsing
595
+ const dateTimeStr = CONST_TIME_INSERTDATE + str;
596
+ const date = new Date(Date.parse(dateTimeStr));
597
+ return isNaN(date.getTime()) ? def : date;
598
+ }
599
+
600
+ // The epoch twins of the two ISO getters, mirroring their branch
601
+ // structure exactly (including which branches fold a failed parse into
602
+ // `def` and which surface it as NaN) so a comparison against either
603
+ // twin's result answers the same.
604
+
605
+ export function getEpochOfISODateTime(str, def = undefined) {
606
+ if (!isValidISODateTime(str))
607
+ return def;
608
+ if (!/[Z+-]\d{2}:\d{2}$/i.test(str) && !str.endsWith('Z')) {
609
+ const ms = Date.parse(str.replace(' ', 'T'));
610
+ return ms !== ms ? def : ms;
611
+ }
612
+ return Date.parse(str);
613
+ }
614
+
615
+ export function getEpochOfISOTime(str, def = undefined) {
616
+ if (!isValidISOTime(str))
617
+ return def;
618
+ const ms = Date.parse(CONST_TIME_INSERTDATE + str);
619
+ return ms !== ms ? def : ms;
620
+ }
621
+ //#endregion