@barefootjs/client 0.24.1 → 0.26.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.
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * `formatDate` — format a `Date` (or ISO-8601 string) with an explicit pattern
3
- * and an explicit UTC offset. The pure-function date formatter of #2324: every
3
+ * and an explicit timezone. The pure-function date formatter of #2324: every
4
4
  * input is explicit, so the output is a deterministic function of its
5
- * arguments — no host locale, no host timezone, no ICU/CLDR data. That is what
6
- * lets the SSR adapters lower a `formatDate(...)` call to their native
7
- * `format_date` runtime helper (spec/template-helpers.md) with byte-identical
8
- * output on every backend.
5
+ * arguments — no host locale, no *implicit* host timezone, no ICU/CLDR data.
6
+ * That is what lets the SSR adapters lower a `formatDate(...)` call to their
7
+ * native `format_date` runtime helper (spec/template-helpers.md) with
8
+ * byte-identical output on every backend.
9
9
  *
10
10
  * ```tsx
11
11
  * <time>{formatDate(createdAt, 'YYYY/M/D', '+09:00')}</time>
12
+ * <time>{formatDate(createdAt, 'YYYY/M/D', 'Asia/Tokyo')}</time>
12
13
  * <time>{formatDate(createdAt, 'MMMM D, YYYY', 'UTC', names)}</time>
13
14
  * ```
14
15
  *
@@ -27,11 +28,18 @@
27
28
  * same normative zero-value discipline as an unparseable date, byte-identical
28
29
  * on every backend.
29
30
  *
30
- * `timeZone` is `'UTC'` or a fixed offset `±HH:MM` (`'+09:00'`, `'-05:30'`).
31
- * The function is total: any other value including IANA zone names, which
32
- * would drag host tzdata versions into the output normalizes to `'UTC'`, so
33
- * every backend degrades identically instead of diverging. An unset or
34
- * unparseable `date` renders `''`.
31
+ * `timeZone` (#2344) is `'UTC'`, a fixed offset `±HH:MM` within ECMA-402's
32
+ * valid range (`'+09:00'`, `'-05:30'`hours 00–23, minutes 00–59), or a
33
+ * **canonical IANA zone ID** (`'Asia/Tokyo'`) resolved through tzdata at the
34
+ * instant being formatted DST-aware, historical-transition-aware, at up to
35
+ * seconds precision (pre-standard LMT offsets count). Any other value — an
36
+ * unknown zone, a non-canonical spelling (`'asia/tokyo'`, a link name the
37
+ * host canonicalizes away), an out-of-range offset (`'+25:00'`) — **throws a
38
+ * `RangeError`**, mirroring `Intl`: loud, never a silent fallback to UTC (a
39
+ * silently substituted timezone is the one failure mode this helper must not
40
+ * have — the pre-#2344 total-function normalization is gone). The receiver
41
+ * contract still comes first: an unset or unparseable `date` renders `''`
42
+ * before `timeZone` is ever inspected, on every backend identically.
35
43
  */
36
44
  export declare function formatDate(date: Date | string, pattern: string, timeZone?: string, names?: readonly string[]): string;
37
45
  //# sourceMappingURL=format-date.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"format-date.d.ts","sourceRoot":"","sources":["../src/format-date.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAeH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,GAAG,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,QAAQ,SAAQ,EAChB,KAAK,GAAE,SAAS,MAAM,EAAO,GAC5B,MAAM,CA4CR"}
1
+ {"version":3,"file":"format-date.d.ts","sourceRoot":"","sources":["../src/format-date.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAoEH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,GAAG,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,QAAQ,SAAQ,EAChB,KAAK,GAAE,SAAS,MAAM,EAAO,GAC5B,MAAM,CAiDR"}
package/dist/index.js CHANGED
@@ -163,12 +163,49 @@ function queryHref(base, params) {
163
163
  return qs ? `${base}?${qs}` : base;
164
164
  }
165
165
  // src/format-date.ts
166
- var OFFSET_RE = /^([+-])(\d{2}):(\d{2})$/;
166
+ var OFFSET_RE = /^([+-])([01]\d|2[0-3]):([0-5]\d)$/;
167
167
  var TOKEN_RE = /YYYY|MMMM|MMM|MM|DD|dddd|ddd|M|D/g;
168
168
  var MONTHS_WIDE = 0;
169
169
  var MONTHS_ABBR = 12;
170
170
  var WEEKDAYS_WIDE = 24;
171
171
  var WEEKDAYS_ABBR = 31;
172
+ var dtfCache = new Map;
173
+ function invalidTimeZone(timeZone) {
174
+ return new RangeError(`formatDate: unresolvable timeZone ${JSON.stringify(timeZone)} — expected 'UTC', a fixed ±HH:MM offset, or a canonical IANA zone ID`);
175
+ }
176
+ function zoneOffsetMs(timeZone, t) {
177
+ let dtf = dtfCache.get(timeZone);
178
+ if (!dtf) {
179
+ try {
180
+ dtf = new Intl.DateTimeFormat("en-US", {
181
+ timeZone,
182
+ hourCycle: "h23",
183
+ era: "short",
184
+ year: "numeric",
185
+ month: "2-digit",
186
+ day: "2-digit",
187
+ hour: "2-digit",
188
+ minute: "2-digit",
189
+ second: "2-digit"
190
+ });
191
+ } catch {
192
+ throw invalidTimeZone(timeZone);
193
+ }
194
+ if (dtf.resolvedOptions().timeZone !== timeZone)
195
+ throw invalidTimeZone(timeZone);
196
+ dtfCache.set(timeZone, dtf);
197
+ }
198
+ const field = {};
199
+ for (const part of dtf.formatToParts(t))
200
+ field[part.type] = part.value;
201
+ let year = Number(field.year);
202
+ if (field.era && field.era.startsWith("B"))
203
+ year = 1 - year;
204
+ const wall = new Date(0);
205
+ wall.setUTCFullYear(year, Number(field.month) - 1, Number(field.day));
206
+ wall.setUTCHours(Number(field.hour), Number(field.minute), Number(field.second), 0);
207
+ return wall.getTime() - Math.floor(t / 1000) * 1000;
208
+ }
172
209
  function pad2(n) {
173
210
  return String(n).padStart(2, "0");
174
211
  }
@@ -179,9 +216,12 @@ function formatDate(date, pattern, timeZone = "UTC", names = []) {
179
216
  const t = d.getTime();
180
217
  if (Number.isNaN(t))
181
218
  return "";
182
- const m = OFFSET_RE.exec(timeZone);
183
- const offsetMinutes = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0;
184
- const s = new Date(t + offsetMinutes * 60000);
219
+ let offsetMs = 0;
220
+ if (timeZone !== "UTC") {
221
+ const m = OFFSET_RE.exec(timeZone);
222
+ offsetMs = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60000 : zoneOffsetMs(timeZone, t);
223
+ }
224
+ const s = new Date(t + offsetMs);
185
225
  const year = s.getUTCFullYear();
186
226
  const yyyy = (year < 0 ? "-" : "") + String(Math.abs(year)).padStart(4, "0");
187
227
  const month = s.getUTCMonth() + 1;
@@ -163,12 +163,49 @@ function queryHref(base, params) {
163
163
  return qs ? `${base}?${qs}` : base;
164
164
  }
165
165
  // src/format-date.ts
166
- var OFFSET_RE = /^([+-])(\d{2}):(\d{2})$/;
166
+ var OFFSET_RE = /^([+-])([01]\d|2[0-3]):([0-5]\d)$/;
167
167
  var TOKEN_RE = /YYYY|MMMM|MMM|MM|DD|dddd|ddd|M|D/g;
168
168
  var MONTHS_WIDE = 0;
169
169
  var MONTHS_ABBR = 12;
170
170
  var WEEKDAYS_WIDE = 24;
171
171
  var WEEKDAYS_ABBR = 31;
172
+ var dtfCache = new Map;
173
+ function invalidTimeZone(timeZone) {
174
+ return new RangeError(`formatDate: unresolvable timeZone ${JSON.stringify(timeZone)} — expected 'UTC', a fixed ±HH:MM offset, or a canonical IANA zone ID`);
175
+ }
176
+ function zoneOffsetMs(timeZone, t) {
177
+ let dtf = dtfCache.get(timeZone);
178
+ if (!dtf) {
179
+ try {
180
+ dtf = new Intl.DateTimeFormat("en-US", {
181
+ timeZone,
182
+ hourCycle: "h23",
183
+ era: "short",
184
+ year: "numeric",
185
+ month: "2-digit",
186
+ day: "2-digit",
187
+ hour: "2-digit",
188
+ minute: "2-digit",
189
+ second: "2-digit"
190
+ });
191
+ } catch {
192
+ throw invalidTimeZone(timeZone);
193
+ }
194
+ if (dtf.resolvedOptions().timeZone !== timeZone)
195
+ throw invalidTimeZone(timeZone);
196
+ dtfCache.set(timeZone, dtf);
197
+ }
198
+ const field = {};
199
+ for (const part of dtf.formatToParts(t))
200
+ field[part.type] = part.value;
201
+ let year = Number(field.year);
202
+ if (field.era && field.era.startsWith("B"))
203
+ year = 1 - year;
204
+ const wall = new Date(0);
205
+ wall.setUTCFullYear(year, Number(field.month) - 1, Number(field.day));
206
+ wall.setUTCHours(Number(field.hour), Number(field.minute), Number(field.second), 0);
207
+ return wall.getTime() - Math.floor(t / 1000) * 1000;
208
+ }
172
209
  function pad2(n) {
173
210
  return String(n).padStart(2, "0");
174
211
  }
@@ -179,9 +216,12 @@ function formatDate(date, pattern, timeZone = "UTC", names = []) {
179
216
  const t = d.getTime();
180
217
  if (Number.isNaN(t))
181
218
  return "";
182
- const m = OFFSET_RE.exec(timeZone);
183
- const offsetMinutes = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0;
184
- const s = new Date(t + offsetMinutes * 60000);
219
+ let offsetMs = 0;
220
+ if (timeZone !== "UTC") {
221
+ const m = OFFSET_RE.exec(timeZone);
222
+ offsetMs = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60000 : zoneOffsetMs(timeZone, t);
223
+ }
224
+ const s = new Date(t + offsetMs);
185
225
  const year = s.getUTCFullYear();
186
226
  const yyyy = (year < 0 ? "-" : "") + String(Math.abs(year)).padStart(4, "0");
187
227
  const month = s.getUTCMonth() + 1;
@@ -816,12 +816,49 @@ function queryHref(base, params) {
816
816
  return qs ? `${base}?${qs}` : base;
817
817
  }
818
818
  // src/format-date.ts
819
- var OFFSET_RE = /^([+-])(\d{2}):(\d{2})$/;
819
+ var OFFSET_RE = /^([+-])([01]\d|2[0-3]):([0-5]\d)$/;
820
820
  var TOKEN_RE = /YYYY|MMMM|MMM|MM|DD|dddd|ddd|M|D/g;
821
821
  var MONTHS_WIDE = 0;
822
822
  var MONTHS_ABBR = 12;
823
823
  var WEEKDAYS_WIDE = 24;
824
824
  var WEEKDAYS_ABBR = 31;
825
+ var dtfCache = new Map;
826
+ function invalidTimeZone(timeZone) {
827
+ return new RangeError(`formatDate: unresolvable timeZone ${JSON.stringify(timeZone)} — expected 'UTC', a fixed ±HH:MM offset, or a canonical IANA zone ID`);
828
+ }
829
+ function zoneOffsetMs(timeZone, t) {
830
+ let dtf = dtfCache.get(timeZone);
831
+ if (!dtf) {
832
+ try {
833
+ dtf = new Intl.DateTimeFormat("en-US", {
834
+ timeZone,
835
+ hourCycle: "h23",
836
+ era: "short",
837
+ year: "numeric",
838
+ month: "2-digit",
839
+ day: "2-digit",
840
+ hour: "2-digit",
841
+ minute: "2-digit",
842
+ second: "2-digit"
843
+ });
844
+ } catch {
845
+ throw invalidTimeZone(timeZone);
846
+ }
847
+ if (dtf.resolvedOptions().timeZone !== timeZone)
848
+ throw invalidTimeZone(timeZone);
849
+ dtfCache.set(timeZone, dtf);
850
+ }
851
+ const field = {};
852
+ for (const part of dtf.formatToParts(t))
853
+ field[part.type] = part.value;
854
+ let year = Number(field.year);
855
+ if (field.era && field.era.startsWith("B"))
856
+ year = 1 - year;
857
+ const wall = new Date(0);
858
+ wall.setUTCFullYear(year, Number(field.month) - 1, Number(field.day));
859
+ wall.setUTCHours(Number(field.hour), Number(field.minute), Number(field.second), 0);
860
+ return wall.getTime() - Math.floor(t / 1000) * 1000;
861
+ }
825
862
  function pad2(n) {
826
863
  return String(n).padStart(2, "0");
827
864
  }
@@ -832,9 +869,12 @@ function formatDate(date, pattern, timeZone = "UTC", names = []) {
832
869
  const t = d.getTime();
833
870
  if (Number.isNaN(t))
834
871
  return "";
835
- const m = OFFSET_RE.exec(timeZone);
836
- const offsetMinutes = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0;
837
- const s = new Date(t + offsetMinutes * 60000);
872
+ let offsetMs = 0;
873
+ if (timeZone !== "UTC") {
874
+ const m = OFFSET_RE.exec(timeZone);
875
+ offsetMs = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60000 : zoneOffsetMs(timeZone, t);
876
+ }
877
+ const s = new Date(t + offsetMs);
838
878
  const year = s.getUTCFullYear();
839
879
  const yyyy = (year < 0 ? "-" : "") + String(Math.abs(year)).padStart(4, "0");
840
880
  const month = s.getUTCMonth() + 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/client",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "directory": "packages/client"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.24.1"
58
+ "@barefootjs/shared": "0.26.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * `formatDate` — format a `Date` (or ISO-8601 string) with an explicit pattern
3
- * and an explicit UTC offset. The pure-function date formatter of #2324: every
3
+ * and an explicit timezone. The pure-function date formatter of #2324: every
4
4
  * input is explicit, so the output is a deterministic function of its
5
- * arguments — no host locale, no host timezone, no ICU/CLDR data. That is what
6
- * lets the SSR adapters lower a `formatDate(...)` call to their native
7
- * `format_date` runtime helper (spec/template-helpers.md) with byte-identical
8
- * output on every backend.
5
+ * arguments — no host locale, no *implicit* host timezone, no ICU/CLDR data.
6
+ * That is what lets the SSR adapters lower a `formatDate(...)` call to their
7
+ * native `format_date` runtime helper (spec/template-helpers.md) with
8
+ * byte-identical output on every backend.
9
9
  *
10
10
  * ```tsx
11
11
  * <time>{formatDate(createdAt, 'YYYY/M/D', '+09:00')}</time>
12
+ * <time>{formatDate(createdAt, 'YYYY/M/D', 'Asia/Tokyo')}</time>
12
13
  * <time>{formatDate(createdAt, 'MMMM D, YYYY', 'UTC', names)}</time>
13
14
  * ```
14
15
  *
@@ -27,14 +28,21 @@
27
28
  * same normative zero-value discipline as an unparseable date, byte-identical
28
29
  * on every backend.
29
30
  *
30
- * `timeZone` is `'UTC'` or a fixed offset `±HH:MM` (`'+09:00'`, `'-05:30'`).
31
- * The function is total: any other value including IANA zone names, which
32
- * would drag host tzdata versions into the output normalizes to `'UTC'`, so
33
- * every backend degrades identically instead of diverging. An unset or
34
- * unparseable `date` renders `''`.
31
+ * `timeZone` (#2344) is `'UTC'`, a fixed offset `±HH:MM` within ECMA-402's
32
+ * valid range (`'+09:00'`, `'-05:30'`hours 00–23, minutes 00–59), or a
33
+ * **canonical IANA zone ID** (`'Asia/Tokyo'`) resolved through tzdata at the
34
+ * instant being formatted DST-aware, historical-transition-aware, at up to
35
+ * seconds precision (pre-standard LMT offsets count). Any other value — an
36
+ * unknown zone, a non-canonical spelling (`'asia/tokyo'`, a link name the
37
+ * host canonicalizes away), an out-of-range offset (`'+25:00'`) — **throws a
38
+ * `RangeError`**, mirroring `Intl`: loud, never a silent fallback to UTC (a
39
+ * silently substituted timezone is the one failure mode this helper must not
40
+ * have — the pre-#2344 total-function normalization is gone). The receiver
41
+ * contract still comes first: an unset or unparseable `date` renders `''`
42
+ * before `timeZone` is ever inspected, on every backend identically.
35
43
  */
36
44
 
37
- const OFFSET_RE = /^([+-])(\d{2}):(\d{2})$/
45
+ const OFFSET_RE = /^([+-])([01]\d|2[0-3]):([0-5]\d)$/
38
46
  const TOKEN_RE = /YYYY|MMMM|MMM|MM|DD|dddd|ddd|M|D/g
39
47
 
40
48
  /** `names`-table section offsets (spec/template-helpers.md "format_date"). */
@@ -43,6 +51,59 @@ const MONTHS_ABBR = 12
43
51
  const WEEKDAYS_WIDE = 24
44
52
  const WEEKDAYS_ABBR = 31
45
53
 
54
+ /** Probe formatter per named zone — construction is the expensive step. */
55
+ const dtfCache = new Map<string, Intl.DateTimeFormat>()
56
+
57
+ function invalidTimeZone(timeZone: string): RangeError {
58
+ return new RangeError(
59
+ `formatDate: unresolvable timeZone ${JSON.stringify(timeZone)} — expected 'UTC', a fixed ±HH:MM offset, or a canonical IANA zone ID`,
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Resolve a canonical IANA zone ID's UTC offset (in ms, always a whole
65
+ * multiple of 1000 — tz offsets are second-granular) at instant `t`, via the
66
+ * environment's own tzdata as ECMA-402 exposes it. The wall-clock fields the
67
+ * zone formats for `t` are re-encoded as-if-UTC and diffed against `t` — no
68
+ * dependency on `timeZoneName: 'longOffset'` support, and seconds-precision
69
+ * LMT offsets fall out naturally. Non-canonical spellings and link names
70
+ * (anything `resolvedOptions().timeZone` does not echo verbatim) throw: the
71
+ * canonical primary ID is the only spelling every backend's tzdata resolves
72
+ * identically, so the portable contract admits exactly that.
73
+ */
74
+ function zoneOffsetMs(timeZone: string, t: number): number {
75
+ let dtf = dtfCache.get(timeZone)
76
+ if (!dtf) {
77
+ try {
78
+ dtf = new Intl.DateTimeFormat('en-US', {
79
+ timeZone,
80
+ hourCycle: 'h23',
81
+ era: 'short',
82
+ year: 'numeric',
83
+ month: '2-digit',
84
+ day: '2-digit',
85
+ hour: '2-digit',
86
+ minute: '2-digit',
87
+ second: '2-digit',
88
+ })
89
+ } catch {
90
+ throw invalidTimeZone(timeZone)
91
+ }
92
+ if (dtf.resolvedOptions().timeZone !== timeZone) throw invalidTimeZone(timeZone)
93
+ dtfCache.set(timeZone, dtf)
94
+ }
95
+ const field: Record<string, string> = {}
96
+ for (const part of dtf.formatToParts(t)) field[part.type] = part.value
97
+ // era 'BC' marks proleptic years <= 0: ISO year = 1 - displayed year.
98
+ let year = Number(field.year)
99
+ if (field.era && field.era.startsWith('B')) year = 1 - year
100
+ // setUTCFullYear, not Date.UTC — Date.UTC maps years 0..99 to 1900+y.
101
+ const wall = new Date(0)
102
+ wall.setUTCFullYear(year, Number(field.month) - 1, Number(field.day))
103
+ wall.setUTCHours(Number(field.hour), Number(field.minute), Number(field.second), 0)
104
+ return wall.getTime() - Math.floor(t / 1000) * 1000
105
+ }
106
+
46
107
  function pad2(n: number): string {
47
108
  return String(n).padStart(2, '0')
48
109
  }
@@ -61,11 +122,16 @@ export function formatDate(
61
122
  const d = date instanceof Date ? date : new Date(date)
62
123
  const t = d.getTime()
63
124
  if (Number.isNaN(t)) return ''
64
- const m = OFFSET_RE.exec(timeZone)
65
- const offsetMinutes = m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0
125
+ let offsetMs = 0
126
+ if (timeZone !== 'UTC') {
127
+ const m = OFFSET_RE.exec(timeZone)
128
+ offsetMs = m
129
+ ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60_000
130
+ : zoneOffsetMs(timeZone, t)
131
+ }
66
132
  // Shift the instant by the offset, then read UTC fields: the shifted UTC
67
- // clock face IS the local clock face at that offset.
68
- const s = new Date(t + offsetMinutes * 60_000)
133
+ // clock face IS the local clock face in that zone at that instant.
134
+ const s = new Date(t + offsetMs)
69
135
  const year = s.getUTCFullYear()
70
136
  const yyyy = (year < 0 ? '-' : '') + String(Math.abs(year)).padStart(4, '0')
71
137
  const month = s.getUTCMonth() + 1