Skip to content

Commit bbcf907

Browse files
authored
feat(bundle): Add Temporal support (#636)
1 parent 48e2a62 commit bbcf907

File tree

9 files changed

+334
-27
lines changed

9 files changed

+334
-27
lines changed

fluent-bundle/package.json

Lines changed: 2 additions & 1 deletion
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

Lines changed: 3 additions & 12 deletions
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 new FluentDateTime(arg, values(opts, DATETIME_ALLOWED));
171162
}
172163

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

fluent-bundle/src/bundle.ts

Lines changed: 1 addition & 2 deletions
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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
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,

fluent-bundle/src/resolver.ts

Lines changed: 3 additions & 3 deletions
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.supportsValue(arg)) {
191+
return new FluentDateTime(arg);
192192
}
193193
// eslint-disable-next-line no-fallthrough
194194
default:

fluent-bundle/src/scope.ts

Lines changed: 2 additions & 1 deletion
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/types.ts

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
import { Scope } from "./scope.js";
22

3+
// Temporary workaround to support environments without Temporal
4+
// Replace with Temporal.* types once they are provided by TypeScript
5+
// In addition to this minimal interface, these objects are also expected
6+
// to be supported by Intl.DateTimeFormat
7+
interface TemporalObject {
8+
epochMilliseconds?: number;
9+
toZonedDateTime?(timeZone: string): { epochMilliseconds: number };
10+
calendarId?: string;
11+
toString(): string;
12+
}
13+
314
export type FluentValue = FluentType<unknown> | string;
415

16+
export type FluentVariable =
17+
| FluentValue
18+
| TemporalObject
19+
| string
20+
| number
21+
| Date;
22+
523
export type FluentFunction = (
624
positional: Array<FluentValue>,
725
named: Record<string, FluentValue>
@@ -104,37 +122,110 @@ export class FluentNumber extends FluentType<number> {
104122
/**
105123
* A `FluentType` representing a date and time.
106124
*
107-
* A `FluentDateTime` instance stores the number value of the date it
108-
* represents, as a numerical timestamp in milliseconds. It may also store an
125+
* A `FluentDateTime` instance stores a Date object, Temporal object, or a number
126+
* as a numerical timestamp in milliseconds. It may also store an
109127
* option bag of options which will be passed to `Intl.DateTimeFormat` when the
110128
* `FluentDateTime` is formatted to a string.
111129
*/
112-
export class FluentDateTime extends FluentType<number> {
130+
export class FluentDateTime extends FluentType<
131+
| number
132+
| Date
133+
| TemporalObject
134+
> {
113135
/** Options passed to `Intl.DateTimeFormat`. */
114136
public opts: Intl.DateTimeFormatOptions;
115137

138+
static supportsValue(value: unknown): value is ConstructorParameters<typeof FluentDateTime>[0] {
139+
if (typeof value === "number") return true;
140+
if (value instanceof Date) return true;
141+
if (value instanceof FluentType) return FluentDateTime.supportsValue(value.valueOf());
142+
// Temporary workaround to support environments without Temporal
143+
if ('Temporal' in globalThis) {
144+
// for TypeScript, which doesn't know about Temporal yet
145+
const _Temporal = (
146+
globalThis as unknown as { Temporal: Record<string, () => unknown> }
147+
).Temporal;
148+
if (
149+
value instanceof _Temporal.Instant ||
150+
value instanceof _Temporal.PlainDateTime ||
151+
value instanceof _Temporal.PlainDate ||
152+
value instanceof _Temporal.PlainMonthDay ||
153+
value instanceof _Temporal.PlainTime ||
154+
value instanceof _Temporal.PlainYearMonth
155+
) {
156+
return true;
157+
}
158+
}
159+
return false
160+
}
161+
116162
/**
117163
* Create an instance of `FluentDateTime` with options to the
118164
* `Intl.DateTimeFormat` constructor.
119165
*
120166
* @param value The number value of this `FluentDateTime`, in milliseconds.
121167
* @param opts Options which will be passed to `Intl.DateTimeFormat`.
122168
*/
123-
constructor(value: number, opts: Intl.DateTimeFormatOptions = {}) {
169+
constructor(
170+
value:
171+
| number
172+
| Date
173+
| TemporalObject
174+
| FluentDateTime
175+
| FluentType<number>,
176+
opts: Intl.DateTimeFormatOptions = {}
177+
) {
178+
// unwrap any FluentType value, but only retain the opts from FluentDateTime
179+
if (value instanceof FluentDateTime) {
180+
opts = { ...value.opts, ...opts };
181+
value = value.value;
182+
} else if (value instanceof FluentType) {
183+
value = value.valueOf();
184+
}
185+
186+
// Intl.DateTimeFormat defaults to gregorian calendar, but Temporal defaults to iso8601
187+
if (typeof value === "object" && 'calendarId' in value && opts.calendar === undefined) {
188+
opts = { ...opts, calendar: value.calendarId };
189+
}
190+
124191
super(value);
125192
this.opts = opts;
126193
}
127194

195+
/**
196+
* Convert this `FluentDateTime` to a number.
197+
* Note that this isn't always possible due to the nature of Temporal objects.
198+
* In such cases, a TypeError will be thrown.
199+
*/
200+
toNumber(): number {
201+
const value = this.value;
202+
if (typeof value === "number") return value;
203+
if (value instanceof Date) return value.getTime();
204+
205+
if ('epochMilliseconds' in value) {
206+
return value.epochMilliseconds as number;
207+
}
208+
209+
if ('toZonedDateTime' in value) {
210+
return value.toZonedDateTime!("UTC").epochMilliseconds;
211+
}
212+
213+
throw new TypeError("Unwrapping a non-number value as a number");
214+
}
215+
128216
/**
129217
* Format this `FluentDateTime` to a string.
130218
*/
131219
toString(scope: Scope): string {
132220
try {
133221
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts);
134-
return dtf.format(this.value);
222+
return dtf.format(this.value as Parameters<Intl.DateTimeFormat["format"]>[0]);
135223
} catch (err) {
136224
scope.reportError(err);
137-
return new Date(this.value).toISOString();
225+
if (typeof this.value === "number" || this.value instanceof Date) {
226+
return new Date(this.value).toISOString();
227+
}
228+
return this.value.toString();
138229
}
139230
}
140231
}

0 commit comments

Comments
 (0)