Skip to content

Commit d3f30bb

Browse files
committed
Implement support for Temporal objects (except Temporal.Duration).
1 parent 48e2a62 commit d3f30bb

File tree

10 files changed

+397
-25
lines changed

10 files changed

+397
-25
lines changed

fluent-bundle/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"npm": ">=7.0.0"
5050
},
5151
"devDependencies": {
52-
"@fluent/dedent": "^0.5.0"
52+
"@fluent/dedent": "^0.5.0",
53+
"temporal-polyfill": "^0.2.5"
5354
}
5455
}

fluent-bundle/src/builtins.ts

+3-12
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function NUMBER(
8888
}
8989

9090
if (arg instanceof FluentDateTime) {
91-
return new FluentNumber(arg.valueOf(), {
91+
return new FluentNumber(arg.toNumber(), {
9292
...values(opts, NUMBER_ALLOWED),
9393
});
9494
}
@@ -157,17 +157,8 @@ export function DATETIME(
157157
return new FluentNone(`DATETIME(${arg.valueOf()})`);
158158
}
159159

160-
if (arg instanceof FluentDateTime) {
161-
return new FluentDateTime(arg.valueOf(), {
162-
...arg.opts,
163-
...values(opts, DATETIME_ALLOWED),
164-
});
165-
}
166-
167-
if (arg instanceof FluentNumber) {
168-
return new FluentDateTime(arg.valueOf(), {
169-
...values(opts, DATETIME_ALLOWED),
170-
});
160+
if (arg instanceof FluentDateTime || arg instanceof FluentNumber) {
161+
return FluentDateTime.from(arg, values(opts, DATETIME_ALLOWED));
171162
}
172163

173164
throw new TypeError("Invalid argument to DATETIME");

fluent-bundle/src/bundle.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { resolveComplexPattern } from "./resolver.js";
22
import { Scope } from "./scope.js";
33
import { FluentResource } from "./resource.js";
4-
import { FluentValue, FluentNone, FluentFunction } from "./types.js";
4+
import { FluentVariable, FluentNone, FluentFunction } from "./types.js";
55
import { Message, Term, Pattern } from "./ast.js";
66
import { NUMBER, DATETIME } from "./builtins.js";
77
import { getMemoizerForLocale, IntlCache } from "./memoizer.js";
88

99
export type TextTransform = (text: string) => string;
10-
export type FluentVariable = FluentValue | string | number | Date;
1110

1211
/**
1312
* Message bundles are single-language stores of translation resources. They are

fluent-bundle/src/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
*/
99

1010
export type { Message } from "./ast.js";
11-
export { FluentBundle, FluentVariable, TextTransform } from "./bundle.js";
11+
export { FluentBundle, TextTransform } from "./bundle.js";
1212
export { FluentResource } from "./resource.js";
1313
export type { Scope } from "./scope.js";
1414
export {
1515
FluentValue,
16+
FluentVariable,
1617
FluentType,
1718
FluentFunction,
1819
FluentNone,
1920
FluentNumber,
2021
FluentDateTime,
22+
FluentDateInfo,
2123
} from "./types.js";

fluent-bundle/src/resolver.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
FluentNone,
3131
FluentNumber,
3232
FluentDateTime,
33+
FluentVariable,
3334
} from "./types.js";
3435
import { Scope } from "./scope.js";
3536
import {
@@ -44,7 +45,6 @@ import {
4445
ComplexPattern,
4546
Pattern,
4647
} from "./ast.js";
47-
import { FluentVariable } from "./bundle.js";
4848

4949
/**
5050
* The maximum number of placeables which can be expanded in a single call to
@@ -187,8 +187,8 @@ function resolveVariableReference(
187187
case "number":
188188
return new FluentNumber(arg);
189189
case "object":
190-
if (arg instanceof Date) {
191-
return new FluentDateTime(arg.getTime());
190+
if (FluentDateTime.isDateTimeValue(arg)) {
191+
return FluentDateTime.from(arg);
192192
}
193193
// eslint-disable-next-line no-fallthrough
194194
default:

fluent-bundle/src/scope.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { FluentBundle, FluentVariable } from "./bundle.js";
1+
import { FluentBundle } from "./bundle.js";
22
import { ComplexPattern } from "./ast.js";
3+
import { FluentVariable } from "./types.js";
34

45
export class Scope {
56
/** The bundle for which the given resolution is happening. */

fluent-bundle/src/temporal.d.ts

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
declare namespace FluentTemporal {
2+
class Duration {
3+
}
4+
5+
class Instant {
6+
epochMilliseconds: number;
7+
toZonedDateTimeISO(timeZone: string): ZonedDateTime;
8+
}
9+
10+
class PlainDate {
11+
calendarId: string;
12+
toZonedDateTime(timeZone: string | { timeZone: string }): ZonedDateTime;
13+
}
14+
15+
class PlainDateTime {
16+
calendarId: string;
17+
withCalendar(calendar: string): PlainDateTime;
18+
toZonedDateTime(timeZone: string | { timeZone: string }): ZonedDateTime;
19+
}
20+
21+
class PlainMonthDay {
22+
calendarId: string;
23+
withCalendar(calendar: string): PlainMonthDay;
24+
}
25+
26+
class PlainTime {
27+
}
28+
29+
class PlainYearMonth {
30+
calendarId: string;
31+
withCalendar(calendar: string): PlainYearMonth;
32+
}
33+
34+
class ZonedDateTime {
35+
timeZoneId: string;
36+
calendarId: string;
37+
epochMilliseconds: number;
38+
withTimeZone(timeZone: string): ZonedDateTime;
39+
withCalendar(calendar: string): ZonedDateTime;
40+
toPlainDateTime(): PlainDateTime;
41+
}
42+
43+
type PlainWithCalendar = PlainDateTime | PlainDate | PlainMonthDay | PlainYearMonth;
44+
type Plain = PlainWithCalendar | PlainDate;
45+
46+
interface TemporalContainer {
47+
Duration: typeof Duration;
48+
Instant: typeof Instant;
49+
PlainDate: typeof PlainDate;
50+
PlainDateTime: typeof PlainDateTime;
51+
PlainMonthDay: typeof PlainMonthDay;
52+
PlainTime: typeof PlainTime;
53+
PlainYearMonth: typeof PlainYearMonth;
54+
ZonedDateTime: typeof ZonedDateTime;
55+
}
56+
}

fluent-bundle/src/types.ts

+122-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
import { Scope } from "./scope.js";
22

33
export type FluentValue = FluentType<unknown> | string;
4+
export type FluentStrictDateValue = FluentTemporal.Plain | Date;
5+
export type FluentDateValue = FluentStrictDateValue | number;
6+
export type FluentDateInfo = FluentDateValue | Date | FluentTemporal.Instant | FluentTemporal.ZonedDateTime;
7+
export type FluentVariable = FluentValue | FluentDateInfo | string | number;
48

59
export type FluentFunction = (
610
positional: Array<FluentValue>,
711
named: Record<string, FluentValue>
812
) => FluentValue;
913

14+
// helper function to determine if a value is a Temporal object, without having to define a global Temporal namespace
15+
function isTemporal(value: any, type: "Duration"): value is FluentTemporal.Duration;
16+
function isTemporal(value: any, type: "Instant"): value is FluentTemporal.Instant;
17+
function isTemporal(value: any, type: "PlainDate"): value is FluentTemporal.PlainDate;
18+
function isTemporal(value: any, type: "PlainDateTime"): value is FluentTemporal.PlainDateTime;
19+
function isTemporal(value: any, type: "PlainMonthDay"): value is FluentTemporal.PlainMonthDay;
20+
function isTemporal(value: any, type: "PlainTime"): value is FluentTemporal.PlainTime;
21+
function isTemporal(value: any, type: "PlainYearMonth"): value is FluentTemporal.PlainYearMonth;
22+
function isTemporal(value: any, type: "ZonedDateTime"): value is FluentTemporal.ZonedDateTime;
23+
function isTemporal(value: any, type: keyof FluentTemporal.TemporalContainer): boolean {
24+
const temporal = (globalThis as any).Temporal as FluentTemporal.TemporalContainer | undefined;
25+
const prototype = temporal && temporal[type];
26+
if (!prototype) {
27+
return false;
28+
}
29+
return value instanceof prototype;
30+
}
31+
1032
/**
1133
* The `FluentType` class is the base of Fluent's type system.
1234
*
@@ -109,32 +131,128 @@ export class FluentNumber extends FluentType<number> {
109131
* option bag of options which will be passed to `Intl.DateTimeFormat` when the
110132
* `FluentDateTime` is formatted to a string.
111133
*/
112-
export class FluentDateTime extends FluentType<number> {
134+
export class FluentDateTime extends FluentType<FluentDateValue> {
113135
/** Options passed to `Intl.DateTimeFormat`. */
114136
public opts: Intl.DateTimeFormatOptions;
115137

138+
/** ignore */
139+
static isDateTimeValue(info: any): info is FluentStrictDateValue {
140+
return info instanceof Date || isTemporal(info, "Instant") ||
141+
isTemporal(info, "PlainDateTime") || isTemporal(info, "PlainDate") ||
142+
isTemporal(info, "PlainMonthDay") || isTemporal(info, "PlainTime") ||
143+
isTemporal(info, "PlainYearMonth") || isTemporal(info, "ZonedDateTime");
144+
}
145+
146+
/**
147+
* Converts various types representing dates and times to a `FluentDateTime`.
148+
*/
149+
static from(
150+
info: FluentDateInfo | FluentType<FluentDateInfo>,
151+
opts: Intl.DateTimeFormatOptions = {}
152+
): FluentDateTime {
153+
if (typeof info === "number") {
154+
return new FluentDateTime(info, opts);
155+
}
156+
157+
if (info instanceof FluentDateTime) {
158+
return new FluentDateTime(info.value, {
159+
...info.opts, ...opts
160+
});
161+
}
162+
163+
if (info instanceof FluentType) {
164+
return FluentDateTime.from(info.valueOf(), opts);
165+
}
166+
167+
if (info instanceof Date) {
168+
return new FluentDateTime(info.getTime(), opts);
169+
}
170+
171+
if (isTemporal(info, "Instant")) {
172+
return new FluentDateTime(info.epochMilliseconds, opts);
173+
}
174+
175+
if (isTemporal(info, "ZonedDateTime")) {
176+
let zoned: FluentTemporal.ZonedDateTime = info;
177+
if (opts.timeZone) {
178+
zoned = info.withTimeZone(opts.timeZone);
179+
} else {
180+
opts = { ...opts, timeZone: info.timeZoneId };
181+
}
182+
if (opts.calendar === undefined) {
183+
opts = { ...opts, calendar: zoned.calendarId };
184+
}
185+
return new FluentDateTime(zoned.epochMilliseconds, opts);
186+
}
187+
188+
if (isTemporal(info, "PlainYearMonth")) {
189+
if (opts.calendar === undefined) {
190+
opts = { ...opts, calendar: info.calendarId };
191+
} else {
192+
info = info.withCalendar(opts.calendar);
193+
}
194+
return new FluentDateTime(info, opts);
195+
}
196+
197+
if (
198+
isTemporal(info, "PlainDateTime") || isTemporal(info, "PlainDate") ||
199+
isTemporal(info, "PlainMonthDay") || isTemporal(info, "PlainTime")
200+
) {
201+
return new FluentDateTime(info, opts);
202+
}
203+
204+
throw new Error("Invalid value passed to FluentDateTime");
205+
}
206+
116207
/**
117208
* Create an instance of `FluentDateTime` with options to the
118209
* `Intl.DateTimeFormat` constructor.
119210
*
120211
* @param value The number value of this `FluentDateTime`, in milliseconds.
121212
* @param opts Options which will be passed to `Intl.DateTimeFormat`.
122213
*/
123-
constructor(value: number, opts: Intl.DateTimeFormatOptions = {}) {
214+
constructor(value: FluentDateValue, opts: Intl.DateTimeFormatOptions = {}) {
124215
super(value);
125216
this.opts = opts;
126217
}
127218

219+
/**
220+
* Convert this `FluentDateTime` to a number.
221+
* Note that this isn't always possible due to the nature of Temporal objects.
222+
* In such cases, a TypeError will be thrown.
223+
*/
224+
toNumber(): number {
225+
const value = this.value;
226+
227+
if (typeof value === "number") {
228+
return value;
229+
}
230+
231+
if (value instanceof Date) {
232+
return value.getTime();
233+
}
234+
235+
if (isTemporal(value, "PlainDateTime") || isTemporal(value, "PlainDate")) {
236+
return value.toZonedDateTime("UTC").epochMilliseconds;
237+
}
238+
239+
throw new TypeError("Unwrapping a non-number value as a number");
240+
}
241+
128242
/**
129243
* Format this `FluentDateTime` to a string.
130244
*/
131245
toString(scope: Scope): string {
132246
try {
133247
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts);
134-
return dtf.format(this.value);
248+
return dtf.format(this.value as Parameters<Intl.DateTimeFormat["format"]>[0]);
135249
} catch (err) {
136250
scope.reportError(err);
137-
return new Date(this.value).toISOString();
251+
if (typeof this.value === "number" || this.value instanceof Date) {
252+
return new Date(this.value).toISOString();
253+
} else {
254+
return this.value.toString();
255+
}
138256
}
139257
}
140258
}

0 commit comments

Comments
 (0)