@ultimat3/time 9.0.0 → 10.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/CLAUDE.md +14 -1
- package/package.json +2 -2
- package/src/cron-describe.ts +4 -13
- package/src/duration.ts +7 -3
- package/src/errors.ts +0 -1
- package/src/format.ts +9 -6
- package/src/locale.ts +24 -0
- package/src/zones.ts +6 -4
package/CLAUDE.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| `zone-canonical.ts` | one zone, one key: `canonicalTimeZone` — the casing/alias collapse every cache keys on |
|
|
13
13
|
| `zoned.ts` | `toZoned` / `fromZoned` + gap and overlap policies. Everything depends on this. |
|
|
14
14
|
| `format.ts` | `Intl` rendering. Every function takes `locale` **and** `zone`. |
|
|
15
|
+
| `locale.ts` | `assertLocale` — the ONE screen a caller-supplied BCP 47 tag passes before `Intl` |
|
|
15
16
|
| `duration.ts` | `'2h30m'` ⇄ ms |
|
|
16
17
|
| `cron.ts` | barrel over the three cron modules — the only one `index.ts` re-exports |
|
|
17
18
|
| `cron-parse.ts` | field grammar → `CronExpression`. Non-integer, non-name tokens are rejected. |
|
|
@@ -43,8 +44,20 @@
|
|
|
43
44
|
(`Japan` → `Asia/Tokyo`, `GB` → `Europe/London`) and that is the point: the slashed spelling is
|
|
44
45
|
the one that survives being a formatter-cache key. **Breaking at 6.0.0.** `zones.test.ts` pins
|
|
45
46
|
one named case per refused name, so an ICU bump that reopens one names it.
|
|
47
|
+
- **A malformed locale tag is `X_LOCALE_INVALID` at EVERY entry point, `As of 2026-08-23`** —
|
|
48
|
+
`formatDateTime`, `formatDate`, `formatTime`, `formatWithOffset`, `formatRange`, `formatRelative`,
|
|
49
|
+
`formatDuration` and `zoneAbbrev`, not `describeCron` alone. Each of the other eight passed the
|
|
50
|
+
caller's raw string to an `Intl` constructor, so `formatRelative(at, { locale: 'en_US', … })` died
|
|
51
|
+
as a bare, uncoded `RangeError` several frames from the `Accept-Language` header it came out of —
|
|
52
|
+
a code that has shipped since 1.0, with a runnable `fix:`, thrown by exactly one of nine callers.
|
|
53
|
+
`format.ts` argued the pass-through decided "a cache key, never whether a locale is acceptable",
|
|
54
|
+
which is true of the cache and was not an argument for letting the tag through. **Breaking at
|
|
55
|
+
9.x.** `assertLocale` (`locale.ts`) is the one screen and it VALIDATES AND CANONICALIZES in one
|
|
56
|
+
step — `Intl.getCanonicalLocales` runs the same structural check `supportedLocalesOf` throws on
|
|
57
|
+
and hands back the spelling the cache keys on, so there is no second question to ask. Well-formed
|
|
58
|
+
but unknown to ICU (`zz`) is **not** refused: `Intl` falls back, and so must a rendered page.
|
|
46
59
|
- **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from
|
|
47
|
-
a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `
|
|
60
|
+
a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `assertLocale`
|
|
48
61
|
for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`,
|
|
49
62
|
`MAX_CACHED_FORMATTERS` and `canonicalLocale` are `@ultimat3/core`'s as of 2.0.0**, not this
|
|
50
63
|
package's: `@ultimat3/money` hit the identical unbounded-`Map`-on-a-header bug and tier 1 may not
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"description": "UTC instants, DST-correct zone math, cron, durations and Intl formatting with an explicit timezone",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/core": "
|
|
37
|
+
"@ultimat3/core": "10.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/cron-describe.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* ship English to every locale that forgot the argument — so injection is mandatory, not opt-in.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { cachedFormatter
|
|
7
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
import { type CronExpression, parseCronOnce } from './cron-parse';
|
|
9
|
-
import { cronNotDescribable
|
|
9
|
+
import { cronNotDescribable } from './errors';
|
|
10
|
+
import { assertLocale } from './locale';
|
|
10
11
|
|
|
11
12
|
export interface CronPhrases {
|
|
12
13
|
everyMinute: string;
|
|
@@ -99,16 +100,6 @@ function clockTimes(
|
|
|
99
100
|
return `${shown} ${fill(phrases.andMore, { n: times.length - MAX_LISTED_TIMES })}`;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
-
* `Intl` throws a bare `RangeError` on a malformed tag; convert it once, at the entry point — and
|
|
104
|
-
* hand back the canonical spelling, so validating and keying are the same single step.
|
|
105
|
-
*/
|
|
106
|
-
function assertLocale(locale: string): string {
|
|
107
|
-
const tag = canonicalLocale(locale);
|
|
108
|
-
if (tag === undefined) throw localeInvalid(locale);
|
|
109
|
-
return tag;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
103
|
/** Step fields: an evenly spaced set starting at 0 that covers the whole range. */
|
|
113
104
|
function uniformStep(values: readonly number[], size: number): number | undefined {
|
|
114
105
|
const first = values[0];
|
|
@@ -130,7 +121,7 @@ function fill(template: string, vars: Readonly<Record<string, string | number>>)
|
|
|
130
121
|
|
|
131
122
|
/**
|
|
132
123
|
* Canonically keyed **and** hard-capped, because `locale` can arrive from an Accept-Language
|
|
133
|
-
* header. `
|
|
124
|
+
* header. `assertLocale` collapses the spellings of one locale — `EN-us`, `en-latn-us` — but it
|
|
134
125
|
* still returns a distinct string for every unknown `-u-` extension value, so the key alone does
|
|
135
126
|
* not bound anything and only the cap keeps the key space finite. Neither half is redundant. The
|
|
136
127
|
* cap and its FIFO live in `@ultimat3/core`'s `intl-cache.ts`, because `zones.ts`, `format.ts` and
|
package/src/duration.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { durationInvalid, scheduleInvalid } from './errors';
|
|
7
|
+
import { assertLocale } from './locale';
|
|
7
8
|
|
|
8
9
|
export const MS = 1;
|
|
9
10
|
export const SECOND = 1000;
|
|
@@ -112,6 +113,9 @@ export function formatDuration(
|
|
|
112
113
|
if (!Number.isInteger(maxUnits) || maxUnits < 1) {
|
|
113
114
|
throw scheduleInvalid('maxUnits', maxUnits, 'at least 1');
|
|
114
115
|
}
|
|
116
|
+
// Screened once, here, rather than at each of the three `Intl` constructions below — and before
|
|
117
|
+
// any of them, so a malformed tag is one refusal and never a partially built string.
|
|
118
|
+
const tag = assertLocale(locale);
|
|
115
119
|
let remaining = Math.abs(Math.round(ms));
|
|
116
120
|
const pieces: string[] = [];
|
|
117
121
|
|
|
@@ -121,19 +125,19 @@ export function formatDuration(
|
|
|
121
125
|
if (count === 0) continue;
|
|
122
126
|
remaining -= count * scale;
|
|
123
127
|
pieces.push(
|
|
124
|
-
new Intl.NumberFormat(
|
|
128
|
+
new Intl.NumberFormat(tag, { style: 'unit', unit, unitDisplay: style }).format(count),
|
|
125
129
|
);
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
if (pieces.length === 0) {
|
|
129
|
-
return new Intl.NumberFormat(
|
|
133
|
+
return new Intl.NumberFormat(tag, {
|
|
130
134
|
style: 'unit',
|
|
131
135
|
unit: 'second',
|
|
132
136
|
unitDisplay: style,
|
|
133
137
|
}).format(0);
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
const joined = new Intl.ListFormat(
|
|
140
|
+
const joined = new Intl.ListFormat(tag, { style: 'narrow', type: 'unit' }).format(pieces);
|
|
137
141
|
return ms < 0 ? `-${joined}` : joined;
|
|
138
142
|
}
|
|
139
143
|
|
package/src/errors.ts
CHANGED
package/src/format.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* because "the server's timezone" is never the answer to "what time is it for the user".
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { cachedFormatter
|
|
7
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
import { differenceMs, type Instant } from './instant';
|
|
9
|
+
import { assertLocale } from './locale';
|
|
9
10
|
import { isoDateInZone } from './zoned';
|
|
10
11
|
import { assertTimeZone, type TimeZone } from './zones';
|
|
11
12
|
|
|
@@ -120,7 +121,7 @@ const RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
|
|
|
120
121
|
/** `in 3 days` / `2 hours ago`, picking the largest unit that fits. */
|
|
121
122
|
export function formatRelative(at: Instant, options: FormatRelativeOptions): string {
|
|
122
123
|
const delta = differenceMs(options.now, at);
|
|
123
|
-
const formatter = new Intl.RelativeTimeFormat(options.locale, {
|
|
124
|
+
const formatter = new Intl.RelativeTimeFormat(assertLocale(options.locale), {
|
|
124
125
|
numeric: options.numeric ?? 'auto',
|
|
125
126
|
style: options.style ?? 'long',
|
|
126
127
|
});
|
|
@@ -176,16 +177,18 @@ export function ordinal(value: number): string {
|
|
|
176
177
|
const cache = new Map<string, Intl.DateTimeFormat>();
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
|
-
* Bounded, and keyed on a zone `assertTimeZone` and a locale `
|
|
180
|
+
* Bounded, and keyed on a zone `assertTimeZone` and a locale `assertLocale` have both already
|
|
180
181
|
* canonicalized — `Accept-Language` sends `EN-us` and `en-US` for one locale, and each spelling
|
|
181
182
|
* used to mint its own permanent entry. The bound stays: an unknown `-u-` extension value survives
|
|
182
183
|
* canonicalization as a distinct string, so only the cap keeps this key space finite.
|
|
183
184
|
*
|
|
184
|
-
* A tag `Intl` cannot parse
|
|
185
|
-
*
|
|
185
|
+
* A tag `Intl` cannot parse is refused here as `X_LOCALE_INVALID`, the same code `describeCron`
|
|
186
|
+
* has always raised. It used to fall through unchanged so the `Intl.DateTimeFormat` constructor
|
|
187
|
+
* raised a bare `RangeError` instead — one package answering "is this locale acceptable" two ways,
|
|
188
|
+
* and the answer a caller could act on was the one seven of the eight entry points did not give.
|
|
186
189
|
*/
|
|
187
190
|
function formatterFor(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
188
|
-
const tag =
|
|
191
|
+
const tag = assertLocale(locale);
|
|
189
192
|
const key = `${tag}|${JSON.stringify(options)}`;
|
|
190
193
|
return cachedFormatter(cache, key, () => new Intl.DateTimeFormat(tag, options));
|
|
191
194
|
}
|
package/src/locale.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: the one place a caller-supplied BCP 47 tag is screened before it reaches
|
|
2
|
+
// an `Intl` constructor. One question, one answer (axiom 1) — `cron-describe.ts` refused a
|
|
3
|
+
// malformed tag from the start while seven sibling formatters handed the raw string to `Intl` and
|
|
4
|
+
// let a bare, uncoded `RangeError` escape several frames from the header it came out of.
|
|
5
|
+
|
|
6
|
+
import { canonicalLocale } from '@ultimat3/core';
|
|
7
|
+
import { localeInvalid } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`.
|
|
11
|
+
*
|
|
12
|
+
* Validating and keying are one step: `Intl.getCanonicalLocales` runs exactly the structural check
|
|
13
|
+
* `Intl.DateTimeFormat.supportedLocalesOf` throws on — which is what `localeInvalid`'s `fix:` tells
|
|
14
|
+
* the caller to run — and unlike it hands back the spelling every formatter cache keys on, so
|
|
15
|
+
* `EN-us` and `en-US` cannot mint two entries for one locale.
|
|
16
|
+
*
|
|
17
|
+
* Well-formed but unknown to this runtime's ICU (`zz`) is NOT refused: `Intl` falls back for those,
|
|
18
|
+
* and a user carrying a locale the runtime has no data for must still get a rendered page.
|
|
19
|
+
*/
|
|
20
|
+
export function assertLocale(locale: string): string {
|
|
21
|
+
const tag = canonicalLocale(locale);
|
|
22
|
+
if (tag === undefined) throw localeInvalid(locale);
|
|
23
|
+
return tag;
|
|
24
|
+
}
|
package/src/zones.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* is no offset table to keep in sync and no `date-fns-tz` dependency.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { cachedFormatter
|
|
7
|
+
import { cachedFormatter } from '@ultimat3/core';
|
|
8
8
|
import { timezoneInvalid } from './errors';
|
|
9
9
|
import type { Instant } from './instant';
|
|
10
|
+
import { assertLocale } from './locale';
|
|
10
11
|
import { canonicalTimeZone } from './zone-canonical';
|
|
11
12
|
|
|
12
13
|
/** An IANA identifier: `Europe/Berlin`, `Asia/Kathmandu`, `UTC`. Never `CET`, never `+01:00`. */
|
|
@@ -118,9 +119,10 @@ export function zoneAbbrev(
|
|
|
118
119
|
style: 'short' | 'long' | 'shortOffset' | 'longOffset' = 'short',
|
|
119
120
|
): string {
|
|
120
121
|
const canonical = assertTimeZone(zone);
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
122
|
+
// Both arguments arrive from a request header on the path this function exists for, so a tag
|
|
123
|
+
// `Intl` cannot parse is refused with a code exactly as an unknown zone is — `X_LOCALE_INVALID`,
|
|
124
|
+
// the same one `describeCron` and every formatter in `format.ts` raise.
|
|
125
|
+
const tag = assertLocale(locale);
|
|
124
126
|
// The one `Intl` construction in this package that escaped the shared cache: it built a formatter
|
|
125
127
|
// per call on the caller's raw zone and locale, so an `x-timezone` an app renders a label from
|
|
126
128
|
// paid for a fresh `Intl.DateTimeFormat` every time and an unknown one escaped as a `RangeError`.
|