@nixxie-cms/fields-timezone 1.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/LICENSE +23 -0
- package/README.md +21 -0
- package/dist/declarations/src/index.d.ts +28 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/nixxie-cms-fields-timezone.cjs.d.ts +2 -0
- package/dist/nixxie-cms-fields-timezone.cjs.js +120 -0
- package/dist/nixxie-cms-fields-timezone.esm.js +114 -0
- package/package.json +33 -0
- package/src/index.ts +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @nixxie-cms/fields-timezone
|
|
2
|
+
|
|
3
|
+
A timezone field for Nixxie CMS. Stores a validated IANA timezone id (e.g. `Asia/Karachi`).
|
|
4
|
+
Validation uses `Intl.supportedValuesOf('timeZone')` when the runtime provides it (cached),
|
|
5
|
+
falling back to probing `Intl.DateTimeFormat` otherwise.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { timezone } from '@nixxie-cms/fields-timezone'
|
|
9
|
+
|
|
10
|
+
fields: {
|
|
11
|
+
tz: timezone({ validation: { isRequired: true } }),
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Also exports:
|
|
16
|
+
|
|
17
|
+
- `listTimezones()` — all IANA ids known to the runtime, or a curated list of ~30 common
|
|
18
|
+
zones as a fallback
|
|
19
|
+
- `currentOffset(tz)` — the zone's current UTC offset, e.g. `"UTC+05:00"` (DST-aware, so it
|
|
20
|
+
varies across the year; on very old runtimes the short style may yield an abbreviation
|
|
21
|
+
like `"PST"`, returned as-is)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TextFieldConfig } from '@nixxie-cms/core/fields';
|
|
2
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types';
|
|
3
|
+
/**
|
|
4
|
+
* All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
|
|
5
|
+
* of ~30 common zones on runtimes without it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function listTimezones(): string[];
|
|
8
|
+
/**
|
|
9
|
+
* The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
|
|
10
|
+
* Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
|
|
11
|
+
* `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
|
|
12
|
+
* returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
|
|
13
|
+
* DST-observing zones return different values across the year.
|
|
14
|
+
*/
|
|
15
|
+
export declare function currentOffset(tz: string): string;
|
|
16
|
+
export type TimezoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<TextFieldConfig<CollectionTypeInfo>, 'validation'> & {
|
|
17
|
+
validation?: {
|
|
18
|
+
/** Reject saving when no value is present. */
|
|
19
|
+
isRequired?: boolean;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
|
|
24
|
+
* field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
|
|
25
|
+
* otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
|
|
26
|
+
*/
|
|
27
|
+
export declare function timezone<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: TimezoneFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC9D,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAmEnF;;;GAGG;AACH,wBAAgB,aAAa,IAAI,MAAM,EAAE,CAGxC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAmBhD;AAED,MAAM,MAAM,mBAAmB,CAAC,kBAAkB,SAAS,sBAAsB,IAAI,IAAI,CACvF,eAAe,CAAC,kBAAkB,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE;QACX,8CAA8C;QAC9C,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;CACF,CAAA;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,kBAAkB,SAAS,sBAAsB,EACxE,MAAM,GAAE,mBAAmB,CAAC,kBAAkB,CAAM,GACnD,aAAa,CAAC,kBAAkB,CAAC,CA2BnC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtdGltZXpvbmUuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var fields = require('@nixxie-cms/core/fields');
|
|
6
|
+
|
|
7
|
+
/** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
|
|
8
|
+
const FALLBACK_TIMEZONES = ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Moscow', 'Europe/Istanbul', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Mexico_City', 'America/Bogota', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'Africa/Cairo', 'Africa/Lagos', 'Africa/Johannesburg', 'Africa/Nairobi', 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Australia/Sydney', 'Pacific/Auckland'];
|
|
9
|
+
|
|
10
|
+
// Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
|
|
11
|
+
// null = the runtime doesn't support it.
|
|
12
|
+
let supportedZones;
|
|
13
|
+
function getSupportedZones() {
|
|
14
|
+
if (supportedZones === undefined) {
|
|
15
|
+
supportedZones = typeof Intl.supportedValuesOf === 'function' ? new Set(Intl.supportedValuesOf('timeZone')) : null;
|
|
16
|
+
}
|
|
17
|
+
return supportedZones;
|
|
18
|
+
}
|
|
19
|
+
function isValidTimezone(tz) {
|
|
20
|
+
var _getSupportedZones;
|
|
21
|
+
// Prefer the runtime's canonical IANA list as a fast path when available.
|
|
22
|
+
if ((_getSupportedZones = getSupportedZones()) !== null && _getSupportedZones !== void 0 && _getSupportedZones.has(tz)) return true;
|
|
23
|
+
// Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
|
|
24
|
+
// covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
|
|
25
|
+
// 'Asia/Calcutta' on some runtimes.
|
|
26
|
+
try {
|
|
27
|
+
new Intl.DateTimeFormat('en-US', {
|
|
28
|
+
timeZone: tz
|
|
29
|
+
});
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
|
|
38
|
+
* of ~30 common zones on runtimes without it.
|
|
39
|
+
*/
|
|
40
|
+
function listTimezones() {
|
|
41
|
+
const zones = getSupportedZones();
|
|
42
|
+
return zones ? [...zones] : [...FALLBACK_TIMEZONES];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
|
|
47
|
+
* Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
|
|
48
|
+
* `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
|
|
49
|
+
* returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
|
|
50
|
+
* DST-observing zones return different values across the year.
|
|
51
|
+
*/
|
|
52
|
+
function currentOffset(tz) {
|
|
53
|
+
var _timeZoneName;
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const timeZoneName = style => {
|
|
56
|
+
try {
|
|
57
|
+
var _Intl$DateTimeFormat$;
|
|
58
|
+
return (_Intl$DateTimeFormat$ = new Intl.DateTimeFormat('en-US', {
|
|
59
|
+
timeZone: tz,
|
|
60
|
+
timeZoneName: style
|
|
61
|
+
}).formatToParts(now).find(part => part.type === 'timeZoneName')) === null || _Intl$DateTimeFormat$ === void 0 ? void 0 : _Intl$DateTimeFormat$.value;
|
|
62
|
+
} catch {
|
|
63
|
+
// Either the runtime doesn't support this timeZoneName style, or tz is unknown.
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const raw = (_timeZoneName = timeZoneName('longOffset')) !== null && _timeZoneName !== void 0 ? _timeZoneName : timeZoneName('short');
|
|
68
|
+
if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`);
|
|
69
|
+
// Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
|
|
70
|
+
const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw);
|
|
71
|
+
if (!match) return raw;
|
|
72
|
+
const [, sign = '+', hours = '0', minutes = '00'] = match;
|
|
73
|
+
return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
|
|
77
|
+
* field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
|
|
78
|
+
* otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
|
|
79
|
+
*/
|
|
80
|
+
function timezone(config = {}) {
|
|
81
|
+
const {
|
|
82
|
+
hooks,
|
|
83
|
+
validation,
|
|
84
|
+
...rest
|
|
85
|
+
} = config;
|
|
86
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
87
|
+
return fields.text({
|
|
88
|
+
...rest,
|
|
89
|
+
validation: {
|
|
90
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
91
|
+
},
|
|
92
|
+
ui: {
|
|
93
|
+
description: 'IANA timezone, e.g. Asia/Karachi',
|
|
94
|
+
...rest.ui
|
|
95
|
+
},
|
|
96
|
+
hooks: {
|
|
97
|
+
...hooks,
|
|
98
|
+
validate: async args => {
|
|
99
|
+
var _userValidate;
|
|
100
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
101
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
102
|
+
const {
|
|
103
|
+
resolvedData,
|
|
104
|
+
fieldKey,
|
|
105
|
+
addValidationError,
|
|
106
|
+
operation
|
|
107
|
+
} = args;
|
|
108
|
+
if (operation === 'delete') return;
|
|
109
|
+
const value = resolvedData[fieldKey];
|
|
110
|
+
if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
|
|
111
|
+
addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
exports.currentOffset = currentOffset;
|
|
119
|
+
exports.listTimezones = listTimezones;
|
|
120
|
+
exports.timezone = timezone;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { text } from '@nixxie-cms/core/fields';
|
|
2
|
+
|
|
3
|
+
/** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
|
|
4
|
+
const FALLBACK_TIMEZONES = ['UTC', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Moscow', 'Europe/Istanbul', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Mexico_City', 'America/Bogota', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'Africa/Cairo', 'Africa/Lagos', 'Africa/Johannesburg', 'Africa/Nairobi', 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Australia/Sydney', 'Pacific/Auckland'];
|
|
5
|
+
|
|
6
|
+
// Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
|
|
7
|
+
// null = the runtime doesn't support it.
|
|
8
|
+
let supportedZones;
|
|
9
|
+
function getSupportedZones() {
|
|
10
|
+
if (supportedZones === undefined) {
|
|
11
|
+
supportedZones = typeof Intl.supportedValuesOf === 'function' ? new Set(Intl.supportedValuesOf('timeZone')) : null;
|
|
12
|
+
}
|
|
13
|
+
return supportedZones;
|
|
14
|
+
}
|
|
15
|
+
function isValidTimezone(tz) {
|
|
16
|
+
var _getSupportedZones;
|
|
17
|
+
// Prefer the runtime's canonical IANA list as a fast path when available.
|
|
18
|
+
if ((_getSupportedZones = getSupportedZones()) !== null && _getSupportedZones !== void 0 && _getSupportedZones.has(tz)) return true;
|
|
19
|
+
// Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
|
|
20
|
+
// covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
|
|
21
|
+
// 'Asia/Calcutta' on some runtimes.
|
|
22
|
+
try {
|
|
23
|
+
new Intl.DateTimeFormat('en-US', {
|
|
24
|
+
timeZone: tz
|
|
25
|
+
});
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
|
|
34
|
+
* of ~30 common zones on runtimes without it.
|
|
35
|
+
*/
|
|
36
|
+
function listTimezones() {
|
|
37
|
+
const zones = getSupportedZones();
|
|
38
|
+
return zones ? [...zones] : [...FALLBACK_TIMEZONES];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
|
|
43
|
+
* Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
|
|
44
|
+
* `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
|
|
45
|
+
* returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
|
|
46
|
+
* DST-observing zones return different values across the year.
|
|
47
|
+
*/
|
|
48
|
+
function currentOffset(tz) {
|
|
49
|
+
var _timeZoneName;
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const timeZoneName = style => {
|
|
52
|
+
try {
|
|
53
|
+
var _Intl$DateTimeFormat$;
|
|
54
|
+
return (_Intl$DateTimeFormat$ = new Intl.DateTimeFormat('en-US', {
|
|
55
|
+
timeZone: tz,
|
|
56
|
+
timeZoneName: style
|
|
57
|
+
}).formatToParts(now).find(part => part.type === 'timeZoneName')) === null || _Intl$DateTimeFormat$ === void 0 ? void 0 : _Intl$DateTimeFormat$.value;
|
|
58
|
+
} catch {
|
|
59
|
+
// Either the runtime doesn't support this timeZoneName style, or tz is unknown.
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const raw = (_timeZoneName = timeZoneName('longOffset')) !== null && _timeZoneName !== void 0 ? _timeZoneName : timeZoneName('short');
|
|
64
|
+
if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`);
|
|
65
|
+
// Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
|
|
66
|
+
const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw);
|
|
67
|
+
if (!match) return raw;
|
|
68
|
+
const [, sign = '+', hours = '0', minutes = '00'] = match;
|
|
69
|
+
return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
|
|
73
|
+
* field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
|
|
74
|
+
* otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
|
|
75
|
+
*/
|
|
76
|
+
function timezone(config = {}) {
|
|
77
|
+
const {
|
|
78
|
+
hooks,
|
|
79
|
+
validation,
|
|
80
|
+
...rest
|
|
81
|
+
} = config;
|
|
82
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
83
|
+
return text({
|
|
84
|
+
...rest,
|
|
85
|
+
validation: {
|
|
86
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
87
|
+
},
|
|
88
|
+
ui: {
|
|
89
|
+
description: 'IANA timezone, e.g. Asia/Karachi',
|
|
90
|
+
...rest.ui
|
|
91
|
+
},
|
|
92
|
+
hooks: {
|
|
93
|
+
...hooks,
|
|
94
|
+
validate: async args => {
|
|
95
|
+
var _userValidate;
|
|
96
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
97
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
98
|
+
const {
|
|
99
|
+
resolvedData,
|
|
100
|
+
fieldKey,
|
|
101
|
+
addValidationError,
|
|
102
|
+
operation
|
|
103
|
+
} = args;
|
|
104
|
+
if (operation === 'delete') return;
|
|
105
|
+
const value = resolvedData[fieldKey];
|
|
106
|
+
if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
|
|
107
|
+
addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { currentOffset, listTimezones, timezone };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/fields-timezone",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-fields-timezone.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-fields-timezone.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-fields-timezone.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-fields-timezone.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-fields-timezone.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.1.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"preconstruct": {
|
|
25
|
+
"entrypoints": [
|
|
26
|
+
"index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-timezone"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { text } from '@nixxie-cms/core/fields'
|
|
2
|
+
import type { TextFieldConfig } from '@nixxie-cms/core/fields'
|
|
3
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types'
|
|
4
|
+
|
|
5
|
+
/** ~30 widely used zones, used only on runtimes without `Intl.supportedValuesOf`. */
|
|
6
|
+
const FALLBACK_TIMEZONES = [
|
|
7
|
+
'UTC',
|
|
8
|
+
'Europe/London',
|
|
9
|
+
'Europe/Paris',
|
|
10
|
+
'Europe/Berlin',
|
|
11
|
+
'Europe/Madrid',
|
|
12
|
+
'Europe/Rome',
|
|
13
|
+
'Europe/Moscow',
|
|
14
|
+
'Europe/Istanbul',
|
|
15
|
+
'America/New_York',
|
|
16
|
+
'America/Chicago',
|
|
17
|
+
'America/Denver',
|
|
18
|
+
'America/Los_Angeles',
|
|
19
|
+
'America/Toronto',
|
|
20
|
+
'America/Mexico_City',
|
|
21
|
+
'America/Bogota',
|
|
22
|
+
'America/Sao_Paulo',
|
|
23
|
+
'America/Argentina/Buenos_Aires',
|
|
24
|
+
'Africa/Cairo',
|
|
25
|
+
'Africa/Lagos',
|
|
26
|
+
'Africa/Johannesburg',
|
|
27
|
+
'Africa/Nairobi',
|
|
28
|
+
'Asia/Dubai',
|
|
29
|
+
'Asia/Karachi',
|
|
30
|
+
'Asia/Kolkata',
|
|
31
|
+
'Asia/Dhaka',
|
|
32
|
+
'Asia/Bangkok',
|
|
33
|
+
'Asia/Singapore',
|
|
34
|
+
'Asia/Hong_Kong',
|
|
35
|
+
'Asia/Shanghai',
|
|
36
|
+
'Asia/Tokyo',
|
|
37
|
+
'Asia/Seoul',
|
|
38
|
+
'Australia/Sydney',
|
|
39
|
+
'Pacific/Auckland',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
// Cached result of Intl.supportedValuesOf('timeZone'): undefined = not yet computed,
|
|
43
|
+
// null = the runtime doesn't support it.
|
|
44
|
+
let supportedZones: ReadonlySet<string> | null | undefined
|
|
45
|
+
|
|
46
|
+
function getSupportedZones(): ReadonlySet<string> | null {
|
|
47
|
+
if (supportedZones === undefined) {
|
|
48
|
+
supportedZones =
|
|
49
|
+
typeof (Intl as any).supportedValuesOf === 'function'
|
|
50
|
+
? new Set<string>((Intl as any).supportedValuesOf('timeZone'))
|
|
51
|
+
: null
|
|
52
|
+
}
|
|
53
|
+
return supportedZones
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isValidTimezone(tz: string): boolean {
|
|
57
|
+
// Prefer the runtime's canonical IANA list as a fast path when available.
|
|
58
|
+
if (getSupportedZones()?.has(tz)) return true
|
|
59
|
+
// Fallback: probe the constructor — it throws a RangeError for unknown timezones. This also
|
|
60
|
+
// covers valid ids missing from supportedValuesOf, which omits aliases such as 'UTC' and
|
|
61
|
+
// 'Asia/Calcutta' on some runtimes.
|
|
62
|
+
try {
|
|
63
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz })
|
|
64
|
+
return true
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* All IANA timezone ids known to the runtime (via `Intl.supportedValuesOf`), or a curated list
|
|
72
|
+
* of ~30 common zones on runtimes without it.
|
|
73
|
+
*/
|
|
74
|
+
export function listTimezones(): string[] {
|
|
75
|
+
const zones = getSupportedZones()
|
|
76
|
+
return zones ? [...zones] : [...FALLBACK_TIMEZONES]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The current UTC offset of a timezone, e.g. `currentOffset('Asia/Karachi')` → `"UTC+05:00"`.
|
|
81
|
+
* Computed via `Intl.DateTimeFormat` with `timeZoneName: 'longOffset'`; on older runtimes the
|
|
82
|
+
* `'short'` style is used instead and may yield an abbreviation (e.g. `"PST"`), which is
|
|
83
|
+
* returned as-is. Throws a `RangeError` for unknown timezones. Note the offset is "current" —
|
|
84
|
+
* DST-observing zones return different values across the year.
|
|
85
|
+
*/
|
|
86
|
+
export function currentOffset(tz: string): string {
|
|
87
|
+
const now = new Date()
|
|
88
|
+
const timeZoneName = (style: 'longOffset' | 'short'): string | undefined => {
|
|
89
|
+
try {
|
|
90
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: style })
|
|
91
|
+
.formatToParts(now)
|
|
92
|
+
.find(part => part.type === 'timeZoneName')?.value
|
|
93
|
+
} catch {
|
|
94
|
+
// Either the runtime doesn't support this timeZoneName style, or tz is unknown.
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const raw = timeZoneName('longOffset') ?? timeZoneName('short')
|
|
99
|
+
if (raw === undefined) throw new RangeError(`Unknown timezone: "${tz}"`)
|
|
100
|
+
// Normalise 'GMT+05:00', 'GMT+5:30', 'GMT-7' or bare 'GMT' (UTC) to 'UTC±HH:MM'.
|
|
101
|
+
const match = /^(?:GMT|UTC)(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(raw)
|
|
102
|
+
if (!match) return raw
|
|
103
|
+
const [, sign = '+', hours = '0', minutes = '00'] = match
|
|
104
|
+
return `UTC${sign}${hours.padStart(2, '0')}:${minutes}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TimezoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
|
|
108
|
+
TextFieldConfig<CollectionTypeInfo>,
|
|
109
|
+
'validation'
|
|
110
|
+
> & {
|
|
111
|
+
validation?: {
|
|
112
|
+
/** Reject saving when no value is present. */
|
|
113
|
+
isRequired?: boolean
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A timezone field storing an IANA timezone id (e.g. `Asia/Karachi`). Built on the core `text`
|
|
119
|
+
* field; values are validated against `Intl.supportedValuesOf('timeZone')` when available,
|
|
120
|
+
* otherwise by probing `Intl.DateTimeFormat`. Empty/null values pass through untouched.
|
|
121
|
+
*/
|
|
122
|
+
export function timezone<CollectionTypeInfo extends BaseCollectionTypeInfo>(
|
|
123
|
+
config: TimezoneFieldConfig<CollectionTypeInfo> = {}
|
|
124
|
+
): FieldTypeFunc<CollectionTypeInfo> {
|
|
125
|
+
const { hooks, validation, ...rest } = config
|
|
126
|
+
const userValidate = hooks?.validate as
|
|
127
|
+
| ((args: any) => unknown)
|
|
128
|
+
| { create?: (args: any) => unknown; update?: (args: any) => unknown; delete?: (args: any) => unknown }
|
|
129
|
+
| undefined
|
|
130
|
+
|
|
131
|
+
return text<CollectionTypeInfo>({
|
|
132
|
+
...(rest as TextFieldConfig<CollectionTypeInfo>),
|
|
133
|
+
validation: { isRequired: validation?.isRequired },
|
|
134
|
+
ui: { description: 'IANA timezone, e.g. Asia/Karachi', ...rest.ui },
|
|
135
|
+
hooks: {
|
|
136
|
+
...hooks,
|
|
137
|
+
validate: async (args: any) => {
|
|
138
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
139
|
+
if (typeof userValidate === 'function') await userValidate(args)
|
|
140
|
+
else await userValidate?.[args.operation as 'create' | 'update' | 'delete']?.(args)
|
|
141
|
+
|
|
142
|
+
const { resolvedData, fieldKey, addValidationError, operation } = args
|
|
143
|
+
if (operation === 'delete') return
|
|
144
|
+
const value = resolvedData[fieldKey]
|
|
145
|
+
if (typeof value === 'string' && value.length > 0 && !isValidTimezone(value)) {
|
|
146
|
+
addValidationError('Must be a valid IANA timezone (e.g. "Asia/Karachi")')
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|