native-dates 0.0.0-stage → 0.0.6
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 +7 -0
- package/README.md +41 -2
- package/package.json +46 -4
- package/src/calendar-date.js +393 -0
- package/src/date-time.js +433 -0
- package/src/duration.js +341 -0
- package/src/helpers/compare.js +126 -0
- package/src/helpers/errors.js +89 -0
- package/src/helpers/fields.js +194 -0
- package/src/helpers/format.js +93 -0
- package/src/helpers/identity.js +10 -0
- package/src/helpers/intl.js +45 -0
- package/src/helpers/types.js +20 -0
- package/src/helpers/units.js +147 -0
- package/src/helpers/zones.js +113 -0
- package/src/index.js +30 -0
- package/src/time.js +357 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { guard, refuse, refuseType } from './errors.js';
|
|
2
|
+
import { IS_DURATION } from './identity.js';
|
|
3
|
+
import { isDevelopment, isNumber, isPlainObject, isString } from './types.js';
|
|
4
|
+
import { isDigits, isTemporalDuration, plural, unit } from './units.js';
|
|
5
|
+
|
|
6
|
+
export const fieldNames = [
|
|
7
|
+
'years',
|
|
8
|
+
'months',
|
|
9
|
+
'weeks',
|
|
10
|
+
'days',
|
|
11
|
+
'hours',
|
|
12
|
+
'minutes',
|
|
13
|
+
'seconds',
|
|
14
|
+
'milliseconds',
|
|
15
|
+
'microseconds',
|
|
16
|
+
'nanoseconds',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// a fractional quantity spills into the next unit down: 1.5 days is a day and 12 hours. a month has no
|
|
20
|
+
// next unit, so a fraction of one is refused rather than guessed
|
|
21
|
+
const spillsInto = {
|
|
22
|
+
year: ['month', 12],
|
|
23
|
+
week: ['day', 7],
|
|
24
|
+
day: ['hour', 24],
|
|
25
|
+
hour: ['minute', 60],
|
|
26
|
+
minute: ['second', 60],
|
|
27
|
+
second: ['millisecond', 1000],
|
|
28
|
+
millisecond: ['microsecond', 1000],
|
|
29
|
+
microsecond: ['nanosecond', 1000],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const spill = (fields, name, value) => {
|
|
33
|
+
if (name === 'quarter') {
|
|
34
|
+
return spill(fields, 'month', value * 3);
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isFinite(value)) {
|
|
37
|
+
refuse('notFinite', `${value} ${name}`);
|
|
38
|
+
}
|
|
39
|
+
const whole = Math.trunc(value);
|
|
40
|
+
const fraction = Number((value - whole).toFixed(9));
|
|
41
|
+
fields[plural(name)] = (fields[plural(name)] ?? 0) + whole;
|
|
42
|
+
if (fraction) {
|
|
43
|
+
const next = spillsInto[name];
|
|
44
|
+
if (!next) {
|
|
45
|
+
refuse('fractionalMonth', `${value} ${name}`, {
|
|
46
|
+
explanation: isDevelopment
|
|
47
|
+
? 'months and nanoseconds have no smaller unit to spill into. write whole months, or days'
|
|
48
|
+
: 0,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
spill(fields, next[0], Number((fraction * next[1]).toFixed(9)));
|
|
52
|
+
}
|
|
53
|
+
return fields;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// the nonzero fields of a Temporal.Duration as a plain object
|
|
57
|
+
export const fieldsOf = (temporal) => {
|
|
58
|
+
const fields = {};
|
|
59
|
+
for (const field of fieldNames) {
|
|
60
|
+
if (temporal[field]) {
|
|
61
|
+
fields[field] = temporal[field];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return fields;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const quantity = /([-+]?)(\d+(?:\.\d+)?)\s*([a-zµ]+)/gi;
|
|
68
|
+
|
|
69
|
+
// '1h 30m', '2 weeks and 3 days', '90 minutes', '1500' (milliseconds).
|
|
70
|
+
// a leading minus with the rest unsigned negates the whole phrase, the way a person means '-1h 30m',
|
|
71
|
+
// and a sign on each quantity is taken as written
|
|
72
|
+
const parseWords = (text) => {
|
|
73
|
+
const phrase = text.trim().replace(/,|\band\b/gi, ' ');
|
|
74
|
+
if (!phrase) {
|
|
75
|
+
refuse('unreadableDuration', text, {
|
|
76
|
+
explanation: isDevelopment ? 'an empty string is not a length. duration(0) is zero' : 0,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (/^-?\d+(\.\d+)?$/.test(phrase)) {
|
|
80
|
+
return spill({}, 'millisecond', Number(phrase));
|
|
81
|
+
}
|
|
82
|
+
const found = [];
|
|
83
|
+
const rest = phrase.replace(quantity, (_, sign, amount, name) => {
|
|
84
|
+
found.push([sign, Number(amount), unit(name)]);
|
|
85
|
+
return '';
|
|
86
|
+
});
|
|
87
|
+
if (rest.trim()) {
|
|
88
|
+
refuse('unreadableDuration', text, {
|
|
89
|
+
explanation: isDevelopment
|
|
90
|
+
? "write quantities with units, '1h 30m' or '2 weeks 3 days', or an ISO duration like PT1H30M"
|
|
91
|
+
: 0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const negateAll = found[0]?.[0] === '-' && found.slice(1).every(([sign]) => sign === '');
|
|
95
|
+
const fields = {};
|
|
96
|
+
for (const [sign, amount, name] of found) {
|
|
97
|
+
spill(fields, name, negateAll || sign === '-' ? -amount : amount);
|
|
98
|
+
}
|
|
99
|
+
return fields;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// a duration has one sign. '1h -30m' has no meaning until it is subtracted from a point
|
|
103
|
+
const oneSign = (fields, at) => {
|
|
104
|
+
const signs = new Set(Object.values(fields).filter(Boolean).map(Math.sign));
|
|
105
|
+
if (signs.size > 1) {
|
|
106
|
+
refuse('mixedSigns', at, {
|
|
107
|
+
explanation: isDevelopment
|
|
108
|
+
? 'a duration has one sign. subtract from a point instead: start.plus(months(1)).minus(days(3))'
|
|
109
|
+
: 0,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return fields;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// every shape a duration can be written as, as plural Temporal fields
|
|
116
|
+
export const fieldsFrom = (input, name) => {
|
|
117
|
+
if (input?.[IS_DURATION]) {
|
|
118
|
+
if (name !== undefined) {
|
|
119
|
+
refuseType('notADuration', `${input} ${name}`, {
|
|
120
|
+
explanation: isDevelopment
|
|
121
|
+
? "a duration already has its units. a unit goes with a number: duration(90, 'minutes')"
|
|
122
|
+
: 0,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return input.toFields();
|
|
126
|
+
}
|
|
127
|
+
if (isTemporalDuration(input)) {
|
|
128
|
+
return fieldsOf(input);
|
|
129
|
+
}
|
|
130
|
+
if (isNumber(input)) {
|
|
131
|
+
return spill({}, name === undefined ? 'millisecond' : unit(name), input);
|
|
132
|
+
}
|
|
133
|
+
if (isString(input)) {
|
|
134
|
+
// a count from a form or a query string is text, and '30' beside 'days' is thirty days
|
|
135
|
+
if (name !== undefined && isDigits(input)) {
|
|
136
|
+
return spill({}, unit(name), Number(input));
|
|
137
|
+
}
|
|
138
|
+
return /^[-+]?P/i.test(input.trim())
|
|
139
|
+
? fieldsOf(guard(() => Temporal.Duration.from(input.trim()), 'unreadableDuration', input))
|
|
140
|
+
: oneSign(parseWords(input), input);
|
|
141
|
+
}
|
|
142
|
+
if (isPlainObject(input)) {
|
|
143
|
+
const fields = {};
|
|
144
|
+
for (const [key, value] of Object.entries(input)) {
|
|
145
|
+
spill(fields, unit(key), value);
|
|
146
|
+
}
|
|
147
|
+
return oneSign(fields, JSON.stringify(input));
|
|
148
|
+
}
|
|
149
|
+
return refuseType('notADuration', String(input), {
|
|
150
|
+
explanation: isDevelopment
|
|
151
|
+
? "a duration is a number with a unit, a phrase like '2h 30m', an ISO string like PT2H30M, or a fields object"
|
|
152
|
+
: 0,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const clockFields = (fields) => {
|
|
157
|
+
const clock = {};
|
|
158
|
+
for (const field of fieldNames.slice(4)) {
|
|
159
|
+
if (fields[field]) {
|
|
160
|
+
clock[field] = fields[field];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return clock;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// years, months, weeks and days stay field-wise: they are calendar units, and folding 25 hours into a
|
|
167
|
+
// day would move a deadline across a daylight saving change. hours and below combine through Temporal
|
|
168
|
+
// so a subtraction balances to one sign: 2h minus 30m is 1h 30m
|
|
169
|
+
export const addFields = (a, b, sign) => {
|
|
170
|
+
const fields = {};
|
|
171
|
+
for (const field of ['years', 'months', 'weeks', 'days']) {
|
|
172
|
+
const sum = (a[field] ?? 0) + sign * (b[field] ?? 0);
|
|
173
|
+
if (sum) {
|
|
174
|
+
fields[field] = sum;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const clockA = temporalDurationOf(clockFields(a));
|
|
178
|
+
const clockB = temporalDurationOf(clockFields(b));
|
|
179
|
+
Object.assign(fields, fieldsOf(sign > 0 ? clockA.add(clockB) : clockA.subtract(clockB)));
|
|
180
|
+
return oneSign(fields, JSON.stringify(fields));
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Temporal refuses an empty fields object, and a zero duration has no nonzero field to offer it
|
|
184
|
+
export const temporalDurationOf = (fields) =>
|
|
185
|
+
Object.keys(fields).length
|
|
186
|
+
? guard(() => Temporal.Duration.from(fields), 'unreadableDuration', JSON.stringify(fields))
|
|
187
|
+
: new Temporal.Duration();
|
|
188
|
+
|
|
189
|
+
export const temporalDurationFrom = (input, name) => temporalDurationOf(fieldsFrom(input, name));
|
|
190
|
+
|
|
191
|
+
// plural keys are how Temporal writes a length, singular keys how it writes a point, so { hours: 2 }
|
|
192
|
+
// is two hours where { hour: 2 } is two o'clock
|
|
193
|
+
export const isDurationFields = (value) =>
|
|
194
|
+
isPlainObject(value) && Object.keys(value).length > 0 && Object.keys(value).every((key) => fieldNames.includes(key));
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { refuse } from './errors.js';
|
|
2
|
+
import { plainDateTimeFormat, zonedDateTimeFormat } from './intl.js';
|
|
3
|
+
import { isDevelopment, isPlainObject, isString } from './types.js';
|
|
4
|
+
import { locale as pickLocale } from './zones.js';
|
|
5
|
+
|
|
6
|
+
const presets = {
|
|
7
|
+
datetime: {
|
|
8
|
+
short: { dateStyle: 'short', timeStyle: 'short' },
|
|
9
|
+
medium: { dateStyle: 'medium', timeStyle: 'short' },
|
|
10
|
+
long: { dateStyle: 'long', timeStyle: 'short' },
|
|
11
|
+
full: { dateStyle: 'full', timeStyle: 'short' },
|
|
12
|
+
date: { dateStyle: 'medium' },
|
|
13
|
+
time: { timeStyle: 'short' },
|
|
14
|
+
month: { month: 'long', year: 'numeric' },
|
|
15
|
+
text: {
|
|
16
|
+
year: 'numeric',
|
|
17
|
+
month: 'short',
|
|
18
|
+
day: 'numeric',
|
|
19
|
+
hour: 'numeric',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
timeZoneName: 'short',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
date: {
|
|
25
|
+
short: { dateStyle: 'short' },
|
|
26
|
+
medium: { dateStyle: 'medium' },
|
|
27
|
+
long: { dateStyle: 'long' },
|
|
28
|
+
full: { dateStyle: 'full' },
|
|
29
|
+
month: { month: 'long', year: 'numeric' },
|
|
30
|
+
},
|
|
31
|
+
time: {
|
|
32
|
+
default: { timeStyle: 'short' },
|
|
33
|
+
short: { timeStyle: 'short' },
|
|
34
|
+
medium: { timeStyle: 'medium' },
|
|
35
|
+
long: { timeStyle: 'long' },
|
|
36
|
+
full: { timeStyle: 'full' },
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// frozen, so a preset keys its formatter by identity and a format call serializes nothing
|
|
41
|
+
for (const kind of Object.values(presets)) {
|
|
42
|
+
for (const options of Object.values(kind)) {
|
|
43
|
+
Object.freeze(options);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// a preset name or an Intl options bag resolves to Intl options, anything else resolves to undefined
|
|
48
|
+
export const intlOptions = (kind, spec) => {
|
|
49
|
+
if (spec === undefined) {
|
|
50
|
+
return presets[kind].default ?? presets[kind].medium;
|
|
51
|
+
}
|
|
52
|
+
if (isPlainObject(spec)) {
|
|
53
|
+
return spec;
|
|
54
|
+
}
|
|
55
|
+
if (!isString(spec)) {
|
|
56
|
+
refuse('unknownFormat', String(spec), {
|
|
57
|
+
explanation: isDevelopment
|
|
58
|
+
? "a format is a preset like 'medium', an Intl options object, or a token pattern with plugins/tokens"
|
|
59
|
+
: 0,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return Object.hasOwn(presets[kind], spec) ? presets[kind][spec] : undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const unknownFormat = (spec) =>
|
|
66
|
+
refuse('unknownFormat', String(spec), {
|
|
67
|
+
explanation: isDevelopment
|
|
68
|
+
? "a format is a preset like 'medium' or an Intl options object. import '@native-dates/plugins/tokens' for patterns like 'YYYY-MM-DD'"
|
|
69
|
+
: 0,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Intl gets epoch milliseconds and a zone on every engine. a date is its UTC midnight read in UTC, a
|
|
73
|
+
// time is that clock on the epoch day, so the fields print unshifted whether Temporal is native or not
|
|
74
|
+
export const epochOf = (kind, subject) => {
|
|
75
|
+
if (kind === 'date') {
|
|
76
|
+
// Date.UTC reads a year under 100 as the 1900s, setUTCFullYear reads it as written
|
|
77
|
+
const stamp = new Date(0);
|
|
78
|
+
stamp.setUTCFullYear(subject.year, subject.month - 1, subject.day);
|
|
79
|
+
return stamp.getTime();
|
|
80
|
+
}
|
|
81
|
+
if (kind === 'time') {
|
|
82
|
+
return Date.UTC(1970, 0, 1, subject.hour, subject.minute, subject.second, subject.millisecond);
|
|
83
|
+
}
|
|
84
|
+
return subject;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const formatter = (kind, options, locale, zone) =>
|
|
88
|
+
kind === 'datetime'
|
|
89
|
+
? zonedDateTimeFormat(pickLocale(locale), options, zone)
|
|
90
|
+
: plainDateTimeFormat(pickLocale(locale), options);
|
|
91
|
+
|
|
92
|
+
export const formatIntl = (kind, subject, options, locale, zone) =>
|
|
93
|
+
formatter(kind, options, locale, zone).format(epochOf(kind, subject));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Symbol.for keys survive cross-realm boundaries (bundles, iframes), so
|
|
2
|
+
// `instanceof DateTime` works against any realm's class definition
|
|
3
|
+
export const IS_DATE_TIME = Symbol.for('native-dates/DateTime');
|
|
4
|
+
export const IS_CALENDAR_DATE = Symbol.for('native-dates/CalendarDate');
|
|
5
|
+
export const IS_TIME = Symbol.for('native-dates/Time');
|
|
6
|
+
export const IS_DURATION = Symbol.for('native-dates/Duration');
|
|
7
|
+
export const IS_RANGE = Symbol.for('native-dates/Range');
|
|
8
|
+
export const IS_DATE_RANGE = Symbol.for('native-dates/DateRange');
|
|
9
|
+
export const IS_DATE_TIME_RANGE = Symbol.for('native-dates/DateTimeRange');
|
|
10
|
+
export const IS_TIME_RANGE = Symbol.for('native-dates/TimeRange');
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { guard } from './errors.js';
|
|
2
|
+
import { isPlainObject } from './types.js';
|
|
3
|
+
|
|
4
|
+
// an Intl constructor costs about a hundred format calls, so each formatter is built once per argument
|
|
5
|
+
// list and reused. the arguments key nested maps, a string by value and a frozen options object by
|
|
6
|
+
// identity, so a preset costs no serialization on a format call. a caller's own object may change
|
|
7
|
+
// between calls, so it keys by its fields. every call of one formatter passes the same number of arguments
|
|
8
|
+
export const cached = (make) => {
|
|
9
|
+
const root = new Map();
|
|
10
|
+
return (...args) => {
|
|
11
|
+
let level = root;
|
|
12
|
+
const last = args.length - 1;
|
|
13
|
+
for (let index = 0; index <= last; index++) {
|
|
14
|
+
const arg = args[index];
|
|
15
|
+
const key = isPlainObject(arg) && !Object.isFrozen(arg) ? JSON.stringify(arg) : arg;
|
|
16
|
+
let next = level.get(key);
|
|
17
|
+
if (next === undefined) {
|
|
18
|
+
next = index === last ? make(...args) : new Map();
|
|
19
|
+
level.set(key, next);
|
|
20
|
+
}
|
|
21
|
+
level = next;
|
|
22
|
+
}
|
|
23
|
+
return level;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// a datetime prints in its zone unless the options name one. a date or a time has no instant, so its
|
|
28
|
+
// fields print through UTC whatever the options say. an unknown option throws as the formatter is built,
|
|
29
|
+
// so the refusal wraps the build alone and a call that finds its formatter pays nothing for it
|
|
30
|
+
export const zonedDateTimeFormat = cached((locale, options, zone) =>
|
|
31
|
+
guard(() => new Intl.DateTimeFormat(locale, { timeZone: zone, ...options }), 'unknownFormat', JSON.stringify(options))
|
|
32
|
+
);
|
|
33
|
+
export const plainDateTimeFormat = cached((locale, options) =>
|
|
34
|
+
guard(
|
|
35
|
+
() => new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }),
|
|
36
|
+
'unknownFormat',
|
|
37
|
+
JSON.stringify(options),
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
export const durationFormat = cached((locale, style, hoursDisplay) =>
|
|
41
|
+
new Intl.DurationFormat(locale, { style, hoursDisplay })
|
|
42
|
+
);
|
|
43
|
+
export const unitFormat = cached((locale, unit, unitDisplay) =>
|
|
44
|
+
new Intl.NumberFormat(locale, { style: 'unit', unit, unitDisplay })
|
|
45
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// leads the file without optional chaining so bundlers fold process.env.NODE_ENV across modules
|
|
2
|
+
export const isDevelopment = typeof process !== 'undefined' ? process.env.NODE_ENV !== 'production' : false;
|
|
3
|
+
|
|
4
|
+
const toString = Object.prototype.toString;
|
|
5
|
+
|
|
6
|
+
export const isObject = (value) => value !== null && typeof value === 'object';
|
|
7
|
+
|
|
8
|
+
export const isPlainObject = (value) => {
|
|
9
|
+
if (!isObject(value)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const proto = Object.getPrototypeOf(value);
|
|
13
|
+
return proto === null || proto === Object.prototype;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isString = (value) => typeof value === 'string';
|
|
17
|
+
export const isNumber = (value) => typeof value === 'number';
|
|
18
|
+
export const isFunction = (value) => typeof value === 'function';
|
|
19
|
+
export const isArray = Array.isArray;
|
|
20
|
+
export const isDate = (value) => toString.call(value) === '[object Date]';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { refuse } from './errors.js';
|
|
2
|
+
import { isDevelopment, isNumber, isPlainObject, isString } from './types.js';
|
|
3
|
+
|
|
4
|
+
if (!globalThis.Temporal) {
|
|
5
|
+
refuse('noTemporal', 'globalThis.Temporal', {
|
|
6
|
+
explanation: isDevelopment
|
|
7
|
+
? 'this library wraps the Temporal API. import @native-dates/polyfill first on a runtime without one'
|
|
8
|
+
: 0,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const inspect = Symbol.for('nodejs.util.inspect.custom');
|
|
13
|
+
|
|
14
|
+
// every common unit spelling plus the calendar units duration arithmetic refuses to guess at
|
|
15
|
+
const spellings = {
|
|
16
|
+
y: 'year',
|
|
17
|
+
yr: 'year',
|
|
18
|
+
yrs: 'year',
|
|
19
|
+
year: 'year',
|
|
20
|
+
years: 'year',
|
|
21
|
+
q: 'quarter',
|
|
22
|
+
qtr: 'quarter',
|
|
23
|
+
quarter: 'quarter',
|
|
24
|
+
quarters: 'quarter',
|
|
25
|
+
mo: 'month',
|
|
26
|
+
mos: 'month',
|
|
27
|
+
month: 'month',
|
|
28
|
+
months: 'month',
|
|
29
|
+
w: 'week',
|
|
30
|
+
wk: 'week',
|
|
31
|
+
wks: 'week',
|
|
32
|
+
week: 'week',
|
|
33
|
+
weeks: 'week',
|
|
34
|
+
d: 'day',
|
|
35
|
+
day: 'day',
|
|
36
|
+
days: 'day',
|
|
37
|
+
h: 'hour',
|
|
38
|
+
hr: 'hour',
|
|
39
|
+
hrs: 'hour',
|
|
40
|
+
hour: 'hour',
|
|
41
|
+
hours: 'hour',
|
|
42
|
+
m: 'minute',
|
|
43
|
+
min: 'minute',
|
|
44
|
+
mins: 'minute',
|
|
45
|
+
minute: 'minute',
|
|
46
|
+
minutes: 'minute',
|
|
47
|
+
s: 'second',
|
|
48
|
+
sec: 'second',
|
|
49
|
+
secs: 'second',
|
|
50
|
+
second: 'second',
|
|
51
|
+
seconds: 'second',
|
|
52
|
+
ms: 'millisecond',
|
|
53
|
+
msec: 'millisecond',
|
|
54
|
+
msecs: 'millisecond',
|
|
55
|
+
millisecond: 'millisecond',
|
|
56
|
+
milliseconds: 'millisecond',
|
|
57
|
+
us: 'microsecond',
|
|
58
|
+
'µs': 'microsecond',
|
|
59
|
+
microsecond: 'microsecond',
|
|
60
|
+
microseconds: 'microsecond',
|
|
61
|
+
ns: 'nanosecond',
|
|
62
|
+
nanosecond: 'nanosecond',
|
|
63
|
+
nanoseconds: 'nanosecond',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const units = [
|
|
67
|
+
'year',
|
|
68
|
+
'month',
|
|
69
|
+
'week',
|
|
70
|
+
'day',
|
|
71
|
+
'hour',
|
|
72
|
+
'minute',
|
|
73
|
+
'second',
|
|
74
|
+
'millisecond',
|
|
75
|
+
'microsecond',
|
|
76
|
+
'nanosecond',
|
|
77
|
+
];
|
|
78
|
+
export const timeUnits = ['hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'];
|
|
79
|
+
|
|
80
|
+
// a number written as text, the way a form or a query string carries one
|
|
81
|
+
export const isDigits = (value) => isString(value) && /^\s*[-+]?\d+(?:\.\d+)?\s*$/.test(value);
|
|
82
|
+
|
|
83
|
+
// day.js keeps M for month and m for minute, and the tokens plugin does too, so the one capital is kept
|
|
84
|
+
export const unit = (input) => {
|
|
85
|
+
const key = input === 'M' ? 'month' : isString(input) ? input.trim().toLowerCase() : '';
|
|
86
|
+
const found = Object.hasOwn(spellings, key) && spellings[key];
|
|
87
|
+
return found || refuse('unknownUnit', String(input), {
|
|
88
|
+
explanation: isDevelopment
|
|
89
|
+
? `units are ${units.join(', ')} or quarter, singular or plural, or an abbreviation like h, min, d`
|
|
90
|
+
: 0,
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const plural = (name) => `${name}s`;
|
|
95
|
+
|
|
96
|
+
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
97
|
+
|
|
98
|
+
// ISO numbering, monday is 1 and sunday is 7
|
|
99
|
+
export const weekdayNumber = (input) => {
|
|
100
|
+
if (isNumber(input) && Number.isInteger(input) && input >= 1 && input <= 7) {
|
|
101
|
+
return input;
|
|
102
|
+
}
|
|
103
|
+
const key = isString(input) ? input.trim().toLowerCase().slice(0, 3) : '';
|
|
104
|
+
const index = key.length === 3 ? weekdays.findIndex((name) => name.startsWith(key)) : -1;
|
|
105
|
+
return index === -1
|
|
106
|
+
? refuse('unknownWeekday', String(input), {
|
|
107
|
+
explanation: isDevelopment ? `weekdays are ${weekdays.join(', ')} or 1 through 7 from monday` : 0,
|
|
108
|
+
})
|
|
109
|
+
: index + 1;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const isZonedDateTime = (value) => value instanceof Temporal.ZonedDateTime;
|
|
113
|
+
export const isInstant = (value) => value instanceof Temporal.Instant;
|
|
114
|
+
export const isPlainDate = (value) => value instanceof Temporal.PlainDate;
|
|
115
|
+
export const isPlainDateTime = (value) => value instanceof Temporal.PlainDateTime;
|
|
116
|
+
export const isPlainTime = (value) => value instanceof Temporal.PlainTime;
|
|
117
|
+
export const isTemporalDuration = (value) => value instanceof Temporal.Duration;
|
|
118
|
+
|
|
119
|
+
export const pad = (value, width = 2) => String(Math.abs(value)).padStart(width, '0');
|
|
120
|
+
|
|
121
|
+
// Temporal truncates 9.5 to 9 before it checks a range, so a fraction is refused before it gets there
|
|
122
|
+
export const wholeNumbers = (values) => values.every((value) => !isNumber(value) || Number.isInteger(value));
|
|
123
|
+
|
|
124
|
+
// set() replaces parts as strictly as a factory reads them: whole numbers, in range
|
|
125
|
+
export const withParts = (temporal, changes) => {
|
|
126
|
+
if (!wholeNumbers(Object.values(changes))) {
|
|
127
|
+
throw new RangeError('a part is a whole number');
|
|
128
|
+
}
|
|
129
|
+
return temporal.with(changes, { overflow: 'reject' });
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// singular keys, the shape Temporal's with() takes, from any spelling a caller wrote
|
|
133
|
+
export const singularKeys = (fields) => {
|
|
134
|
+
const out = {};
|
|
135
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
136
|
+
out[unit(key)] = value;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const quarterStart = (month) => Math.floor((month - 1) / 3) * 3 + 1;
|
|
142
|
+
|
|
143
|
+
// one whole unit as duration fields
|
|
144
|
+
export const stepOf = (name) => (name === 'quarter' ? { months: 3 } : { [plural(name)]: 1 });
|
|
145
|
+
|
|
146
|
+
// the second argument of a factory is a zone, or an options bag { zone, loose }
|
|
147
|
+
export const zoneOptions = (value) => (isPlainObject(value) ? value : { zone: value });
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { guard, refuse, requireLoose } from './errors.js';
|
|
2
|
+
import { isDevelopment, isPlainObject, isString } from './types.js';
|
|
3
|
+
import { weekdayNumber } from './units.js';
|
|
4
|
+
|
|
5
|
+
// process-wide defaults, zoneAliases being the names a zone answers to when plugins/zones is loaded
|
|
6
|
+
const settings = { zone: undefined, locale: undefined, weekStart: 1, dayFirst: false, zoneAliases: {} };
|
|
7
|
+
const resolved = new Map([['UTC', 'UTC']]);
|
|
8
|
+
|
|
9
|
+
export const canonicalZone = (id) => {
|
|
10
|
+
try {
|
|
11
|
+
return Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(id).timeZoneId;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let zoneExtension = null;
|
|
19
|
+
|
|
20
|
+
export const setZoneExtension = (resolver) => {
|
|
21
|
+
zoneExtension = resolver;
|
|
22
|
+
resolved.clear();
|
|
23
|
+
resolved.set('UTC', 'UTC');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// an IANA name, a fixed offset, or UTC in core, plus cities, abbreviations and zoneAliases when
|
|
27
|
+
// plugins/zones is loaded. resolves once per spelling and hands back the canonical id
|
|
28
|
+
export const zoneId = (id) => {
|
|
29
|
+
if (id === undefined || id === null) {
|
|
30
|
+
return settings.zone ?? Temporal.Now.timeZoneId();
|
|
31
|
+
}
|
|
32
|
+
if (!isString(id)) {
|
|
33
|
+
refuse('unknownZone', String(id), {
|
|
34
|
+
explanation: isDevelopment
|
|
35
|
+
? 'a zone is an IANA name like America/New_York, a fixed offset like +05:30, or UTC'
|
|
36
|
+
: 0,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const known = resolved.get(id);
|
|
40
|
+
if (known) {
|
|
41
|
+
return known;
|
|
42
|
+
}
|
|
43
|
+
const found = zoneExtension ? zoneExtension(id, settings.zoneAliases) : canonicalZone(id.trim());
|
|
44
|
+
if (!found) {
|
|
45
|
+
refuse('unknownZone', id, {
|
|
46
|
+
explanation: isDevelopment
|
|
47
|
+
? zoneExtension
|
|
48
|
+
? "a zone is an IANA name like America/New_York, a city like Berlin or los angeles, an abbreviation like PT or CET, a fixed offset like +05:30, or UTC. name your own once: configure({ zoneAliases: { hq: 'Europe/Berlin' } })"
|
|
49
|
+
: "a zone is an IANA name like America/New_York, a fixed offset like +05:30, or UTC. import '@native-dates/plugins/zones' for cities like Berlin, abbreviations like PT, and zoneAliases"
|
|
50
|
+
: 0,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
resolved.set(id, found);
|
|
54
|
+
return found;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const configure = ({ zone, locale, weekStart, dayFirst, zoneAliases } = {}) => {
|
|
58
|
+
if (zoneAliases !== undefined) {
|
|
59
|
+
if (!zoneExtension) {
|
|
60
|
+
refuse('missingPlugin', 'zoneAliases', {
|
|
61
|
+
explanation: isDevelopment
|
|
62
|
+
? "import '@native-dates/plugins/zones' for zoneAliases"
|
|
63
|
+
: 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (!isPlainObject(zoneAliases)) {
|
|
67
|
+
refuse('unknownZone', String(zoneAliases), {
|
|
68
|
+
explanation: isDevelopment ? "zoneAliases is an object of names to zones: { hq: 'Europe/Berlin' }" : 0,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
for (const [name, target] of Object.entries(zoneAliases)) {
|
|
72
|
+
settings.zoneAliases[name] = zoneId(target);
|
|
73
|
+
}
|
|
74
|
+
resolved.clear();
|
|
75
|
+
resolved.set('UTC', 'UTC');
|
|
76
|
+
}
|
|
77
|
+
if (zone !== undefined) {
|
|
78
|
+
settings.zone = zone === null ? undefined : zoneId(zone);
|
|
79
|
+
}
|
|
80
|
+
if (locale !== undefined) {
|
|
81
|
+
settings.locale = locale === null ? undefined : checkedLocale(locale);
|
|
82
|
+
}
|
|
83
|
+
if (weekStart !== undefined) {
|
|
84
|
+
settings.weekStart = weekdayNumber(weekStart);
|
|
85
|
+
}
|
|
86
|
+
if (dayFirst !== undefined) {
|
|
87
|
+
requireLoose('dayFirst');
|
|
88
|
+
settings.dayFirst = !!dayFirst;
|
|
89
|
+
}
|
|
90
|
+
return { ...settings, zoneAliases: { ...settings.zoneAliases } };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// an Accept-Language header is the classic first attempt, and Intl would refuse it far from the cause
|
|
94
|
+
const knownLocales = new Set();
|
|
95
|
+
const checkedLocale = (tag) => {
|
|
96
|
+
const key = String(tag);
|
|
97
|
+
if (!knownLocales.has(key)) {
|
|
98
|
+
guard(
|
|
99
|
+
() => Intl.getCanonicalLocales(tag),
|
|
100
|
+
'unknownLocale',
|
|
101
|
+
key,
|
|
102
|
+
isDevelopment
|
|
103
|
+
? "a locale is a BCP 47 tag like 'en-US' or 'de', or a list of them. pick one out of an Accept-Language header"
|
|
104
|
+
: 0,
|
|
105
|
+
);
|
|
106
|
+
knownLocales.add(key);
|
|
107
|
+
}
|
|
108
|
+
return tag;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const locale = (override) => (override === undefined ? settings.locale : checkedLocale(override));
|
|
112
|
+
export const dayFirst = (override) => (override === undefined ? settings.dayFirst : !!override);
|
|
113
|
+
export const weekStart = (override) => (override === undefined ? settings.weekStart : weekdayNumber(override));
|
package/src/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// nouns
|
|
2
|
+
export { CalendarDate } from './calendar-date.js';
|
|
3
|
+
export { DateTime } from './date-time.js';
|
|
4
|
+
export { Duration } from './duration.js';
|
|
5
|
+
export { Time } from './time.js';
|
|
6
|
+
|
|
7
|
+
// factories
|
|
8
|
+
export { date, today, tomorrow, yesterday } from './calendar-date.js';
|
|
9
|
+
export { datetime, endOfToday, now, startOfToday } from './date-time.js';
|
|
10
|
+
export { days, duration, hours, milliseconds, minutes, months, seconds, weeks, years } from './duration.js';
|
|
11
|
+
export { time } from './time.js';
|
|
12
|
+
|
|
13
|
+
// guards, comparisons, brands and setup
|
|
14
|
+
export { isCalendarDate } from './calendar-date.js';
|
|
15
|
+
export { isDateTime } from './date-time.js';
|
|
16
|
+
export { isDuration } from './duration.js';
|
|
17
|
+
export { compare, earliest, kindOf, latest } from './helpers/compare.js';
|
|
18
|
+
export {
|
|
19
|
+
IS_CALENDAR_DATE,
|
|
20
|
+
IS_DATE_RANGE,
|
|
21
|
+
IS_DATE_TIME,
|
|
22
|
+
IS_DATE_TIME_RANGE,
|
|
23
|
+
IS_DURATION,
|
|
24
|
+
IS_RANGE,
|
|
25
|
+
IS_TIME,
|
|
26
|
+
IS_TIME_RANGE,
|
|
27
|
+
} from './helpers/identity.js';
|
|
28
|
+
export { weekdayNumber as weekday } from './helpers/units.js';
|
|
29
|
+
export { configure } from './helpers/zones.js';
|
|
30
|
+
export { isTime } from './time.js';
|