@jarenjs/core 0.46.5 → 0.56.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 (42) hide show
  1. package/ARCHITECTURE.md +122 -9
  2. package/README.md +50 -3
  3. package/dist/types/async.d.ts +39 -0
  4. package/dist/types/dates/civil.d.ts +24 -5
  5. package/dist/types/dates/index.d.ts +2 -0
  6. package/dist/types/dates/parse.d.ts +47 -0
  7. package/dist/types/dates/ticks.d.ts +59 -0
  8. package/dist/types/math/float64.d.ts +13 -0
  9. package/dist/types/random.d.ts +80 -0
  10. package/dist/types/series/asof.d.ts +88 -0
  11. package/dist/types/series/bucket.d.ts +206 -0
  12. package/dist/types/series/downsample.d.ts +53 -0
  13. package/dist/types/series/index.d.ts +8 -0
  14. package/dist/types/series/interval-index.d.ts +47 -0
  15. package/dist/types/series/interval.d.ts +170 -0
  16. package/dist/types/series/normalize.d.ts +190 -0
  17. package/dist/types/series/rolling.d.ts +67 -0
  18. package/dist/types/series/selector.d.ts +29 -0
  19. package/dist/types/series/zone.d.ts +59 -0
  20. package/dist/types/stats.d.ts +73 -0
  21. package/docs/DATES.md +77 -1
  22. package/docs/GEO.md +2 -2
  23. package/docs/SERIES.md +342 -0
  24. package/package.json +21 -1
  25. package/src/async.js +78 -0
  26. package/src/dates/civil.js +136 -41
  27. package/src/dates/index.js +4 -0
  28. package/src/dates/parse.js +410 -0
  29. package/src/dates/ticks.js +184 -0
  30. package/src/math/float64.js +29 -0
  31. package/src/random.js +125 -0
  32. package/src/series/asof.js +276 -0
  33. package/src/series/bucket.js +542 -0
  34. package/src/series/downsample.js +343 -0
  35. package/src/series/index.js +86 -0
  36. package/src/series/interval-index.js +181 -0
  37. package/src/series/interval.js +374 -0
  38. package/src/series/normalize.js +330 -0
  39. package/src/series/rolling.js +229 -0
  40. package/src/series/selector.js +104 -0
  41. package/src/series/zone.js +188 -0
  42. package/src/stats.js +133 -0
@@ -205,7 +205,26 @@ export function fixedUnitMs(unit) {
205
205
 
206
206
  //#endregion
207
207
 
208
- //#region arithmetic over parts
208
+ //#region lexical precision
209
+
210
+ // A parts record spells one of three lexical families (rfc3339.js): a
211
+ // full-date with no clock, a full-time with no calendar, or both. An
212
+ // operation that reads a half the value has not got has no answer, and
213
+ // the alternative to refusing is to guess one - which is how `end-of`
214
+ // hour on a full-date used to answer the day BEFORE it, and how adding
215
+ // a day to a full-time used to be a silent no-op.
216
+ //
217
+ // Which half an operation reads follows from its unit:
218
+ //
219
+ // year, quarter, month the calendar; a full-time has none
220
+ // week, day the calendar, plus the clock when the
221
+ // amount carries a fraction of a day
222
+ // hour .. millisecond the clock; a full-date has none
223
+ //
224
+ // `day` and coarser truncate to a boundary a calendar has, so
225
+ // `start-of` day of a full-time is midnight and needs no date. A
226
+ // sub-day truncation names a boundary INSIDE a day, which a value with
227
+ // no clock does not have.
209
228
 
210
229
  // A parts record with no time half reads -1 for hours/minutes/seconds;
211
230
  // arithmetic treats that as midnight but must not *introduce* a time, so
@@ -214,6 +233,48 @@ function hasTime(parts) {
214
233
  return parts.hours >= 0;
215
234
  }
216
235
 
236
+ /** Whether a parts record carries a calendar half. */
237
+ function hasDate(parts) {
238
+ return parts.year >= 0;
239
+ }
240
+
241
+ function requireDate(parts, unit) {
242
+ if (!hasDate(parts))
243
+ throw new TypeError(`'${unit}' needs a date half, and this value has none`);
244
+ }
245
+
246
+ function requireTime(parts, unit) {
247
+ if (!hasTime(parts))
248
+ throw new TypeError(`'${unit}' needs a time half, and this value has none`);
249
+ }
250
+
251
+ /**
252
+ * Split a millisecond offset into a day into clock fields. `seconds`
253
+ * carries the fraction, as everywhere else in this module.
254
+ * @param {number} rest - milliseconds since midnight, [0, 86400000)
255
+ * @returns {{ hours: number, minutes: number, seconds: number }}
256
+ */
257
+ function clockFromDayMs(rest) {
258
+ const hours = Math.floor(rest / 3600000);
259
+ rest -= hours * 3600000;
260
+ const minutes = Math.floor(rest / 60000);
261
+ rest -= minutes * 60000;
262
+ return { hours, minutes, seconds: rest / 1000 };
263
+ }
264
+
265
+ /** Milliseconds since midnight for a parts record that carries a clock. */
266
+ function dayMsOf(parts) {
267
+ return parts.hours * 3600000 + parts.minutes * 60000 + Math.round(parts.seconds * 1000);
268
+ }
269
+
270
+ //#endregion
271
+
272
+ //#region arithmetic over parts
273
+
274
+ // how many months each calendar unit is worth; a fraction of one of
275
+ // these is only a quantity when it converts to a whole month
276
+ const CALENDAR_MONTHS = Object.freeze({ year: 12, quarter: 3, month: 1 });
277
+
217
278
  function withTime(out, parts, hours, minutes, seconds) {
218
279
  if (hasTime(parts)) {
219
280
  out.hours = hours;
@@ -233,68 +294,91 @@ function withTime(out, parts, hours, minutes, seconds) {
233
294
  /**
234
295
  * Add a signed amount of calendar units to a parts record, returning a
235
296
  * new one. The input is never mutated and its lexical shape is kept: a
236
- * full-date stays a full-date, and a value keeps its own UTC offset
237
- * rather than being normalized.
297
+ * full-date stays a full-date, a full-time stays a full-time, and a
298
+ * value keeps its own UTC offset rather than being normalized.
238
299
  *
239
300
  * Month and year arithmetic **clamps** to the end of the target month —
240
301
  * 2026-01-31 plus one month is 2026-02-28 — which is the rule every
241
302
  * mainstream date library uses, because the alternative (overflowing
242
303
  * into March) makes `add(1, 'month')` non-monotonic.
243
304
  *
305
+ * A fraction is a quantity only where the unit has an exact conversion.
306
+ * A fixed-width fraction becomes whole milliseconds, so `1.5 day` is
307
+ * thirty-six hours; a calendar fraction is refused unless it lands on a
308
+ * whole month, because half of January is not a length. The clock those
309
+ * milliseconds land on has to exist: a full-date can be moved by whole
310
+ * days but not by half of one.
311
+ *
244
312
  * @param {object} parts - a parts record from `parseRFC3339Parts`
245
- * @param {number} amount - signed count, may be fractional only for
246
- * fixed-width units (a fractional month has no meaning)
313
+ * @param {number} amount - signed count, fractional only where the unit
314
+ * converts exactly and the value has the half to carry it
247
315
  * @param {string} unit - a {@link DATE_UNITS} member
248
316
  * @returns {object} a new parts record
317
+ * @throws {TypeError} for an unknown unit, a fraction with no exact
318
+ * conversion, or a value missing a half the operation reads
249
319
  */
250
320
  export function addToParts(parts, amount, unit) {
251
321
  if (amount === 0)
252
322
  return { ...parts };
253
- if (unit === 'year' || unit === 'quarter' || unit === 'month') {
254
- const months = unit === 'year' ? amount * 12 : unit === 'quarter' ? amount * 3 : amount;
255
- const total = (parts.year * 12 + (parts.month - 1)) + Math.trunc(months);
323
+ const perMonth = CALENDAR_MONTHS[unit];
324
+ if (perMonth !== undefined) {
325
+ const months = amount * perMonth;
326
+ if (!Number.isInteger(months))
327
+ throw new TypeError(`a fraction of a '${unit}' has no exact calendar length`);
328
+ requireDate(parts, unit);
329
+ const total = (parts.year * 12 + (parts.month - 1)) + months;
256
330
  const year = Math.floor(total / 12);
257
331
  const month = total - year * 12 + 1;
258
332
  const day = Math.min(parts.day, daysInMonth(year, month)); // clamp
259
333
  return withTime({ year, month, day }, parts, parts.hours, parts.minutes, parts.seconds);
260
334
  }
335
+ const width = FIXED_MS[unit];
336
+ if (width === undefined)
337
+ throw new TypeError(`'${unit}' is not a calendar unit`);
261
338
  if (unit === 'day' || unit === 'week') {
262
- const days = unit === 'week' ? amount * 7 : amount;
263
- const z = daysFromCivil(parts.year, parts.month, parts.day) + Math.trunc(days);
264
- const civil = civilFromDays(z);
265
- return withTime(civil, parts, parts.hours, parts.minutes, parts.seconds);
339
+ requireDate(parts, unit);
340
+ if (Number.isInteger(amount)) {
341
+ // whole days move the day number rather than a span of
342
+ // milliseconds: that keeps a full-date a full-date, and stays
343
+ // exact past the range milliseconds can count
344
+ const z = daysFromCivil(parts.year, parts.month, parts.day)
345
+ + amount * (unit === 'week' ? 7 : 1);
346
+ return withTime(civilFromDays(z), parts, parts.hours, parts.minutes, parts.seconds);
347
+ }
266
348
  }
267
- // fixed sub-day units: carry through the day number so a time crossing
268
- // midnight moves the date with it
269
- const ms = FIXED_MS[unit];
270
- if (ms === undefined)
271
- throw new TypeError(`'${unit}' is not a calendar unit`);
272
- const z = daysFromCivil(parts.year, parts.month, parts.day);
273
- const dayMs = hasTime(parts)
274
- ? parts.hours * 3600000 + parts.minutes * 60000 + Math.round(parts.seconds * 1000)
275
- : 0;
276
- const moved = z * 86400000 + dayMs + amount * ms;
349
+ // a span of milliseconds: the sub-day units, and the fraction of a day
350
+ // or week that only a clock can hold
351
+ requireTime(parts, unit);
352
+ const ms = Math.round(amount * width);
353
+ if (!hasDate(parts)) {
354
+ // no calendar to carry into, so the clock wraps inside its own day
355
+ const dayMs = ((dayMsOf(parts) + ms) % 86400000 + 86400000) % 86400000;
356
+ return { year: -1, month: -1, day: -1, ...clockFromDayMs(dayMs), offset: parts.offset };
357
+ }
358
+ // carry through the day number, so a time crossing midnight moves the
359
+ // date with it
360
+ const moved = daysFromCivil(parts.year, parts.month, parts.day) * 86400000
361
+ + dayMsOf(parts) + ms;
277
362
  const dz = Math.floor(moved / 86400000);
278
- let rest = moved - dz * 86400000;
279
- const civil = civilFromDays(dz);
280
- const hours = Math.floor(rest / 3600000);
281
- rest -= hours * 3600000;
282
- const minutes = Math.floor(rest / 60000);
283
- rest -= minutes * 60000;
284
- // a value with no time half acquires one as soon as a sub-day unit
285
- // moves it - there is nowhere else for the result to live
286
- const out = { ...civil, hours, minutes, seconds: rest / 1000, offset: parts.offset };
287
- if (out.offset === null && !hasTime(parts))
288
- out.offset = 0; // a bare full-date is UTC midnight by rfc3339.js's rule
289
- return out;
363
+ return {
364
+ ...civilFromDays(dz), ...clockFromDayMs(moved - dz * 86400000), offset: parts.offset,
365
+ };
290
366
  }
291
367
 
292
368
  /**
293
369
  * Truncate a parts record to the start of a calendar unit, returning a
294
370
  * new one. `week` starts on Monday (ISO 8601).
371
+ *
372
+ * `day` and coarser name a boundary the calendar has, so the start of
373
+ * the day of a full-time is midnight. A sub-day unit names a boundary
374
+ * inside a day, which a value with no clock does not have, and is
375
+ * refused rather than answered from a clock that is not there.
376
+ *
295
377
  * @param {object} parts - a parts record
296
378
  * @param {string} unit - a {@link DATE_UNITS} member
297
379
  * @returns {object} a new parts record
380
+ * @throws {TypeError} for an unknown unit, or a value missing a half
381
+ * the truncation reads
298
382
  */
299
383
  export function startOfParts(parts, unit) {
300
384
  let { year, month, day } = parts;
@@ -303,12 +387,14 @@ export function startOfParts(parts, unit) {
303
387
  let seconds = parts.seconds;
304
388
  switch (unit) {
305
389
  case 'year':
306
- month = 1; day = 1; break;
390
+ requireDate(parts, unit); month = 1; day = 1; break;
307
391
  case 'quarter':
392
+ requireDate(parts, unit);
308
393
  month = (quarterOfYear(parts) - 1) * 3 + 1; day = 1; break;
309
394
  case 'month':
310
- day = 1; break;
395
+ requireDate(parts, unit); day = 1; break;
311
396
  case 'week': {
397
+ requireDate(parts, unit);
312
398
  const z = daysFromCivil(year, month, day);
313
399
  ({ year, month, day } = civilFromDays(z - (isoWeekdayFromDays(z) - 1)));
314
400
  break;
@@ -316,12 +402,13 @@ export function startOfParts(parts, unit) {
316
402
  case 'day':
317
403
  break;
318
404
  case 'hour':
319
- minutes = 0; seconds = 0; break;
405
+ requireTime(parts, unit); minutes = 0; seconds = 0; break;
320
406
  case 'minute':
321
- seconds = 0; break;
407
+ requireTime(parts, unit); seconds = 0; break;
322
408
  case 'second':
323
- seconds = Math.trunc(seconds); break;
409
+ requireTime(parts, unit); seconds = Math.trunc(seconds); break;
324
410
  case 'millisecond':
411
+ requireTime(parts, unit);
325
412
  return { ...parts };
326
413
  default:
327
414
  throw new TypeError(`'${unit}' is not a calendar unit`);
@@ -339,16 +426,24 @@ export function startOfParts(parts, unit) {
339
426
  * The last representable instant inside a calendar unit: the start of
340
427
  * the next unit less one millisecond. A value with no time half is
341
428
  * truncated to the unit's last *day* instead, so a full-date stays a
342
- * full-date.
429
+ * full-date, and it refuses the same sub-day units `startOfParts` does.
343
430
  * @param {object} parts - a parts record
344
431
  * @param {string} unit - a {@link DATE_UNITS} member
345
432
  * @returns {object} a new parts record
433
+ * @throws {TypeError} for an unknown unit, or a value missing a half
434
+ * the truncation reads
346
435
  */
347
436
  export function endOfParts(parts, unit) {
348
437
  const start = startOfParts(parts, unit);
349
438
  if (unit === 'millisecond')
350
439
  return start;
351
- const next = addToParts(start, 1, unit === 'quarter' ? 'quarter' : unit);
440
+ if (!hasDate(parts)) {
441
+ // a clock has no next day to step back from, so the end is the
442
+ // unit's own width less a millisecond, inside the day it lives in
443
+ const rest = dayMsOf(start) + FIXED_MS[unit] - 1;
444
+ return { year: -1, month: -1, day: -1, ...clockFromDayMs(rest), offset: parts.offset };
445
+ }
446
+ const next = addToParts(start, 1, unit);
352
447
  if (!hasTime(parts)) {
353
448
  // date-only: step back one whole day rather than one millisecond
354
449
  const z = daysFromCivil(next.year, next.month, next.day) - 1;
@@ -16,7 +16,9 @@
16
16
  // rfc3339.js validation, lexical decomposition, epoch conversion
17
17
  // civil.js proleptic Gregorian arithmetic over integers
18
18
  // format.js LDML pattern -> compiled formatter
19
+ // parse.js LDML pattern -> compiled strict parser
19
20
  // duration.js ISO 8601 duration decomposition and conversion
21
+ // ticks.js the time-axis step ladder and its boundaries
20
22
  //
21
23
  // Locale-dependent presentation (month and weekday names, relative
22
24
  // phrasing) is NOT here: it belongs to @jarenjs/locales, so this module
@@ -25,6 +27,8 @@
25
27
  export * from './rfc3339.js';
26
28
  export * from './civil.js';
27
29
  export * from './format.js';
30
+ export * from './parse.js';
28
31
  export * from './duration.js';
32
+ export * from './ticks.js';
29
33
 
30
34
  //#endregion
@@ -0,0 +1,410 @@
1
+ //@ts-check
2
+
3
+ //#region Date parsing
4
+ // The inverse of `compileDateFormat`: a pattern is scanned once into a
5
+ // chain of *readers*, and calling the result only walks the chain. The
6
+ // shape is deliberately the same two-stage compiler the formatter uses,
7
+ // for the same reason — a `parse(text, pattern)` helper re-scans its
8
+ // pattern on every row, and a Gantt chart with ten thousand tasks reads
9
+ // ten thousand dates through one pattern.
10
+ //
11
+ // No regular expression is built here, per parse or per compile: every
12
+ // reader is a closure over integers, and digits are read by char code.
13
+ //
14
+ // The vocabulary is the formatter's (Unicode LDML, `yyyy-MM-dd`), minus
15
+ // the tokens that are *derived* from a date rather than part of one.
16
+ // `EEEE` (weekday name), `DDD` (day of year), `ww` (ISO week) and `Q`
17
+ // (quarter) all format, and none of them is an independent field, so
18
+ // asking this compiler for one is an error rather than a token that
19
+ // silently reads nothing.
20
+ //
21
+ // Consumers whose patterns are NOT LDML (Mermaid's Gantt directives use
22
+ // moment's grammar, a d3 axis uses strftime) adapt their tokens to this
23
+ // vocabulary; they do not hand their own spelling to this compiler.
24
+
25
+ import { daysInMonth } from './civil.js';
26
+
27
+ // `DateNames` is format.js's typedef and stays there: re-declaring it
28
+ // here would export the same name from two modules of one barrel, which
29
+ // is an ambiguous re-export in the generated declarations — a defect
30
+ // only the packed-consumer gate sees.
31
+
32
+ // char codes
33
+ const C_0 = 48;
34
+ const C_9 = 57;
35
+ const C_QUOTE = 39;
36
+ const C_PLUS = 43;
37
+ const C_MINUS = 45;
38
+ const C_COLON = 58;
39
+ const C_Z_UPPER = 90;
40
+ const C_Z_LOWER = 122;
41
+
42
+ /**
43
+ * Read between `min` and `max` ASCII digits at `i`.
44
+ * @param {string} text
45
+ * @param {number} i
46
+ * @param {number} min
47
+ * @param {number} max
48
+ * @returns {[number, number]} `[value, next]`, or `[-1, -1]` on failure
49
+ */
50
+ function readDigits(text, i, min, max) {
51
+ let value = 0;
52
+ let n = 0;
53
+ while (n < max) {
54
+ const c = text.charCodeAt(i + n);
55
+ // past the end `charCodeAt` is NaN, and NaN fails BOTH comparisons —
56
+ // so this has to be written as the positive test or the loop reads
57
+ // six characters of nothing and returns NaN
58
+ if (!(c >= C_0 && c <= C_9)) break;
59
+ value = value * 10 + (c - C_0);
60
+ n++;
61
+ }
62
+ if (n < min) return [-1, -1];
63
+ return [value, i + n];
64
+ }
65
+
66
+ /**
67
+ * A field reader: `(text, i, out) => next index`, or -1.
68
+ * @typedef {(text: string, i: number, out: any) => number} Reader
69
+ */
70
+
71
+ /**
72
+ * Build a reader for a numeric field.
73
+ * @param {string} field - the `out` member to assign
74
+ * @param {number} min - minimum digits
75
+ * @param {number} max - maximum digits
76
+ * @returns {Reader}
77
+ */
78
+ function numeric(field, min, max) {
79
+ return (text, i, out) => {
80
+ const [value, next] = readDigits(text, i, min, max);
81
+ if (next < 0) return -1;
82
+ out[field] = value;
83
+ return next;
84
+ };
85
+ }
86
+
87
+ /** The two-digit-year pivot: 00–68 is 2000–2068, 69–99 is 1969–1999. */
88
+ const YY_PIVOT = 69;
89
+
90
+ /** @type {Reader} */
91
+ const readYY = (text, i, out) => {
92
+ const [value, next] = readDigits(text, i, 2, 2);
93
+ if (next < 0) return -1;
94
+ out.year = value < YY_PIVOT ? 2000 + value : 1900 + value;
95
+ return next;
96
+ };
97
+
98
+ /** @type {Reader} */
99
+ const readSignedYear = (text, i, out) => {
100
+ let j = i;
101
+ let sign = 1;
102
+ if (text.charCodeAt(j) === C_MINUS) { sign = -1; j++; }
103
+ const [value, next] = readDigits(text, j, 1, 6);
104
+ if (next < 0) return -1;
105
+ out.year = sign * value;
106
+ return next;
107
+ };
108
+
109
+ /**
110
+ * A name reader: longest candidate first, so a name that is a prefix of
111
+ * another (`Ju` in a hypothetical catalog) cannot shadow it.
112
+ * @param {string} field
113
+ * @param {string[]} names
114
+ * @param {number} base - the value of `names[0]`
115
+ * @returns {Reader}
116
+ */
117
+ function nameOf(field, names, base) {
118
+ // index by descending length once, at compile time
119
+ const order = names.map((name, index) => ({ name, value: index + base }))
120
+ .sort((a, b) => b.name.length - a.name.length);
121
+ const count = order.length;
122
+ return (text, i, out) => {
123
+ for (let k = 0; k < count; k++) {
124
+ const { name, value } = order[k];
125
+ if (name.length !== 0 && text.startsWith(name, i)) {
126
+ out[field] = value;
127
+ return i + name.length;
128
+ }
129
+ }
130
+ return -1;
131
+ };
132
+ }
133
+
134
+ /**
135
+ * @param {boolean} colon - require `+HH:MM` rather than `+HHMM`
136
+ * @param {boolean} zForUtc - accept a bare `Z`
137
+ * @returns {Reader}
138
+ */
139
+ function offsetOf(colon, zForUtc) {
140
+ return (text, i, out) => {
141
+ const c = text.charCodeAt(i);
142
+ if (zForUtc && (c === C_Z_UPPER || c === C_Z_LOWER)) {
143
+ out.offset = 0;
144
+ return i + 1;
145
+ }
146
+ if (c !== C_PLUS && c !== C_MINUS) return -1;
147
+ const [hh, afterH] = readDigits(text, i + 1, 2, 2);
148
+ if (afterH < 0) return -1;
149
+ let j = afterH;
150
+ if (colon) {
151
+ if (text.charCodeAt(j) !== C_COLON) return -1;
152
+ j++;
153
+ }
154
+ const [mm, afterM] = readDigits(text, j, 2, 2);
155
+ if (afterM < 0) return -1;
156
+ if (hh > 23 || mm > 59) return -1;
157
+ out.offset = (c === C_MINUS ? -1 : 1) * (hh * 60 + mm);
158
+ return afterM;
159
+ };
160
+ }
161
+
162
+ /** @type {Reader} */
163
+ const readMillis = (text, i, out) => {
164
+ const [value, next] = readDigits(text, i, 3, 3);
165
+ if (next < 0) return -1;
166
+ out.millis = value;
167
+ return next;
168
+ };
169
+
170
+ /** @type {Reader} */
171
+ const readTenths = (text, i, out) => {
172
+ const [value, next] = readDigits(text, i, 1, 1);
173
+ if (next < 0) return -1;
174
+ out.millis = value * 100;
175
+ return next;
176
+ };
177
+
178
+ /**
179
+ * Which half of a value each token contributes to, and how to read it.
180
+ * `names` is the `DateNames` member a token needs, if any.
181
+ */
182
+ const TOKENS = Object.freeze({
183
+ yyyy: { half: 'date', reader: numeric('year', 4, 4) },
184
+ yy: { half: 'date', reader: readYY },
185
+ y: { half: 'date', reader: readSignedYear },
186
+ MMMM: { half: 'date', names: 'months' },
187
+ MMM: { half: 'date', names: 'monthsShort' },
188
+ MM: { half: 'date', reader: numeric('month', 2, 2) },
189
+ M: { half: 'date', reader: numeric('month', 1, 2) },
190
+ dd: { half: 'date', reader: numeric('day', 2, 2) },
191
+ d: { half: 'date', reader: numeric('day', 1, 2) },
192
+ HH: { half: 'time', reader: numeric('hours', 2, 2) },
193
+ H: { half: 'time', reader: numeric('hours', 1, 2) },
194
+ hh: { half: 'time', reader: numeric('hour12', 2, 2) },
195
+ h: { half: 'time', reader: numeric('hour12', 1, 2) },
196
+ mm: { half: 'time', reader: numeric('minutes', 2, 2) },
197
+ m: { half: 'time', reader: numeric('minutes', 1, 2) },
198
+ ss: { half: 'time', reader: numeric('seconds', 2, 2) },
199
+ s: { half: 'time', reader: numeric('seconds', 1, 2) },
200
+ SSS: { half: 'time', reader: readMillis },
201
+ S: { half: 'time', reader: readTenths },
202
+ a: { half: 'time', names: 'meridiem' },
203
+ XXX: { half: 'offset', reader: offsetOf(true, true) },
204
+ XX: { half: 'offset', reader: offsetOf(false, true) },
205
+ X: { half: 'offset', reader: offsetOf(true, false) },
206
+ });
207
+
208
+ /**
209
+ * Tokens `compileDateFormat` writes that no value carries as a field —
210
+ * they are computed FROM a date, so reading one back tells the parser
211
+ * nothing it did not already have. Named individually so the error says
212
+ * which token and why, rather than "unknown token".
213
+ */
214
+ const DERIVED = Object.freeze({
215
+ EEEE: 'the weekday name', EEE: 'the abbreviated weekday name',
216
+ E: 'the ISO weekday number', DDD: 'the day of the year',
217
+ D: 'the day of the year', ww: 'the ISO week number',
218
+ w: 'the ISO week number', Q: 'the quarter',
219
+ });
220
+
221
+ const MAX_TOKEN = 4;
222
+
223
+ /**
224
+ * The value a name token reads into, and the base of its numbering.
225
+ * @param {string} token
226
+ * @returns {[string, number]}
227
+ */
228
+ function nameTarget(token) {
229
+ if (token === 'a') return ['meridiem', 0];
230
+ return ['month', 1];
231
+ }
232
+
233
+ /**
234
+ * Compile an LDML date pattern into a strict parser.
235
+ *
236
+ * The returned function takes a string and returns a parts record in
237
+ * exactly the shape `parseRFC3339Parts` produces — so
238
+ * `epochOfRFC3339Parts`, `formatRFC3339Parts`, `addToParts` and every
239
+ * other calendar function accept it unchanged — or `null` when the text
240
+ * does not match. Failure is `null`, never a throw and never a partial
241
+ * record: a malformed *pattern* is the programmer's error and throws at
242
+ * compile time; malformed *text* is data and is reported as no match.
243
+ *
244
+ * Strict means three things:
245
+ *
246
+ * 1. **Full consumption.** Trailing text fails; `yyyy-MM-dd` does not
247
+ * read `2026-01-02T03:04`.
248
+ * 2. **Impossible civil dates fail.** 31 February and hour 24 are not
249
+ * silently rolled forward into March and the next day.
250
+ * 3. **Fixed-width tokens are fixed width.** `MM` reads exactly two
251
+ * digits, `M` one or two, and neither reads three.
252
+ *
253
+ * The lexical family of the result follows the pattern: a pattern with
254
+ * only date tokens yields a full-date (`hours`/`minutes`/`seconds` are
255
+ * the contract's `-1`), one with only time tokens a full-time
256
+ * (`year`/`month`/`day` are `-1`), and one with both a date-time. A
257
+ * pattern with no field token at all is a compile error — it could only
258
+ * ever return the same empty record.
259
+ *
260
+ * Two tokens are not strict inverses of an arbitrary value and are
261
+ * documented rather than refused: `yy` reads 00–68 as 2000–2068 and
262
+ * 69–99 as 1969–1999 (the POSIX pivot moment uses), and `h`/`hh`
263
+ * without an `a` in the same pattern read as the morning, so 12 is
264
+ * midnight. Both round-trip their own spelling exactly.
265
+ *
266
+ * @param {string} pattern - an LDML pattern, e.g. `'yyyy-MM-dd'`
267
+ * @param {import('./format.js').DateNames} [names] - locale names, required only if the
268
+ * pattern uses `MMM`/`MMMM`/`a`
269
+ * @returns {(text: string) => object | null} the compiled parser
270
+ * @throws {TypeError} on an unterminated quote, a derived token, a name
271
+ * token with no provider, or a pattern that reads no field
272
+ * @example
273
+ * const read = compileDateParser('dd-MM-yyyy');
274
+ * read('06-01-2014'); // { year: 2014, month: 1, day: 6, hours: -1, … }
275
+ * read('06-01-2014x'); // null — trailing input
276
+ * read('31-02-2014'); // null — February has no 31st
277
+ */
278
+ export function compileDateParser(pattern, names = undefined) {
279
+ if (typeof pattern !== 'string')
280
+ throw new TypeError('a date pattern must be a string');
281
+
282
+ /** @type {Reader[]} */
283
+ const steps = [];
284
+ let literal = '';
285
+ let hasDate = false;
286
+ let hasTime = false;
287
+ let hasOffset = false;
288
+ let hasMeridiem = false;
289
+
290
+ const flushLiteral = () => {
291
+ if (literal !== '') {
292
+ const text = literal;
293
+ const width = text.length;
294
+ steps.push((source, i) => (source.startsWith(text, i) ? i + width : -1));
295
+ literal = '';
296
+ }
297
+ };
298
+
299
+ for (let i = 0; i < pattern.length;) {
300
+ const ch = pattern[i];
301
+ if (ch === "'") { // quoted literal, '' is one quote
302
+ if (pattern.charCodeAt(i + 1) === C_QUOTE) {
303
+ literal += "'";
304
+ i += 2;
305
+ continue;
306
+ }
307
+ const end = pattern.indexOf("'", i + 1);
308
+ if (end < 0)
309
+ throw new TypeError(`unterminated quoted literal in date pattern '${pattern}'`);
310
+ literal += pattern.slice(i + 1, end);
311
+ i = end + 1;
312
+ continue;
313
+ }
314
+ let matched = null;
315
+ for (let len = MAX_TOKEN; len >= 1; len--) {
316
+ const candidate = pattern.slice(i, i + len);
317
+ if (candidate.length !== len) continue;
318
+ if (TOKENS[candidate] !== undefined || DERIVED[candidate] !== undefined) {
319
+ matched = candidate;
320
+ break;
321
+ }
322
+ }
323
+ if (matched === null) {
324
+ literal += ch;
325
+ i += 1;
326
+ continue;
327
+ }
328
+ if (DERIVED[matched] !== undefined) {
329
+ throw new TypeError(`the '${matched}' token writes ${DERIVED[matched]},`
330
+ + ' which is derived from a date rather than part of one, so it cannot be parsed');
331
+ }
332
+ const spec = TOKENS[matched];
333
+ let reader = spec.reader;
334
+ if (spec.names !== undefined) {
335
+ const table = names === undefined ? undefined : names[spec.names];
336
+ if (table === undefined)
337
+ throw new TypeError(`the '${matched}' token needs a '${spec.names}' names provider`
338
+ + ' (@jarenjs/core/dates is locale-free by design)');
339
+ const [field, base] = nameTarget(matched);
340
+ reader = nameOf(field, table, base);
341
+ if (matched === 'a') hasMeridiem = true;
342
+ }
343
+ flushLiteral();
344
+ steps.push(reader);
345
+ if (spec.half === 'date') hasDate = true;
346
+ else if (spec.half === 'time') hasTime = true;
347
+ else hasOffset = true;
348
+ i += matched.length;
349
+ }
350
+ flushLiteral();
351
+
352
+ if (!hasDate && !hasTime)
353
+ throw new TypeError(`the date pattern '${pattern}' reads no date or time field`);
354
+
355
+ const count = steps.length;
356
+ return (text) => {
357
+ if (typeof text !== 'string') return null;
358
+ /** @type {any} */
359
+ const out = {
360
+ year: 1970, month: 1, day: 1,
361
+ hours: 0, minutes: 0, seconds: 0, millis: 0,
362
+ hour12: -1, meridiem: -1, offset: null,
363
+ };
364
+ let i = 0;
365
+ for (let k = 0; k < count; k++) {
366
+ i = steps[k](text, i, out);
367
+ if (i < 0) return null;
368
+ }
369
+ if (i !== text.length) return null;
370
+ return assemble(out, hasDate, hasTime, hasOffset, hasMeridiem);
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Turn the reader's scratch record into a validated parts record.
376
+ * @param {any} out
377
+ * @param {boolean} hasDate
378
+ * @param {boolean} hasTime
379
+ * @param {boolean} hasOffset
380
+ * @param {boolean} hasMeridiem
381
+ * @returns {object | null}
382
+ */
383
+ function assemble(out, hasDate, hasTime, hasOffset, hasMeridiem) {
384
+ if (hasDate) {
385
+ if (out.month < 1 || out.month > 12) return null;
386
+ if (out.day < 1 || out.day > daysInMonth(out.year, out.month)) return null;
387
+ }
388
+ let hours = out.hours;
389
+ if (hasTime) {
390
+ if (out.hour12 >= 0) {
391
+ if (out.hour12 < 1 || out.hour12 > 12) return null;
392
+ hours = out.hour12 % 12;
393
+ if (hasMeridiem && out.meridiem === 1) hours += 12;
394
+ }
395
+ if (hours < 0 || hours > 23) return null;
396
+ if (out.minutes < 0 || out.minutes > 59) return null;
397
+ if (out.seconds < 0 || out.seconds > 59) return null;
398
+ }
399
+ return {
400
+ year: hasDate ? out.year : -1,
401
+ month: hasDate ? out.month : -1,
402
+ day: hasDate ? out.day : -1,
403
+ hours: hasTime ? hours : -1,
404
+ minutes: hasTime ? out.minutes : -1,
405
+ seconds: hasTime ? out.seconds + out.millis / 1000 : -1,
406
+ offset: hasOffset ? out.offset : null,
407
+ };
408
+ }
409
+
410
+ //#endregion