Skip to content

Add Temporal support #636

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion fluent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"npm": ">=7.0.0"
},
"devDependencies": {
"@fluent/dedent": "^0.5.0"
"@fluent/dedent": "^0.5.0",
"temporal-polyfill": "^0.2.5"
}
}
15 changes: 3 additions & 12 deletions fluent-bundle/src/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function NUMBER(
}

if (arg instanceof FluentDateTime) {
return new FluentNumber(arg.valueOf(), {
return new FluentNumber(arg.toNumber(), {
...values(opts, NUMBER_ALLOWED),
});
}
Expand Down Expand Up @@ -157,17 +157,8 @@ export function DATETIME(
return new FluentNone(`DATETIME(${arg.valueOf()})`);
}

if (arg instanceof FluentDateTime) {
return new FluentDateTime(arg.valueOf(), {
...arg.opts,
...values(opts, DATETIME_ALLOWED),
});
}

if (arg instanceof FluentNumber) {
return new FluentDateTime(arg.valueOf(), {
...values(opts, DATETIME_ALLOWED),
});
if (arg instanceof FluentDateTime || arg instanceof FluentNumber) {
return new FluentDateTime(arg, values(opts, DATETIME_ALLOWED));
}

throw new TypeError("Invalid argument to DATETIME");
Expand Down
3 changes: 1 addition & 2 deletions fluent-bundle/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { resolveComplexPattern } from "./resolver.js";
import { Scope } from "./scope.js";
import { FluentResource } from "./resource.js";
import { FluentValue, FluentNone, FluentFunction } from "./types.js";
import { FluentVariable, FluentNone, FluentFunction } from "./types.js";
import { Message, Term, Pattern } from "./ast.js";
import { NUMBER, DATETIME } from "./builtins.js";
import { getMemoizerForLocale, IntlCache } from "./memoizer.js";

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

/**
* Message bundles are single-language stores of translation resources. They are
Expand Down
3 changes: 2 additions & 1 deletion fluent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
*/

export type { Message } from "./ast.js";
export { FluentBundle, FluentVariable, TextTransform } from "./bundle.js";
export { FluentBundle, TextTransform } from "./bundle.js";
export { FluentResource } from "./resource.js";
export type { Scope } from "./scope.js";
export {
FluentValue,
FluentVariable,
FluentType,
FluentFunction,
FluentNone,
Expand Down
6 changes: 3 additions & 3 deletions fluent-bundle/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
FluentNone,
FluentNumber,
FluentDateTime,
FluentVariable,
} from "./types.js";
import { Scope } from "./scope.js";
import {
Expand All @@ -44,7 +45,6 @@ import {
ComplexPattern,
Pattern,
} from "./ast.js";
import { FluentVariable } from "./bundle.js";

/**
* The maximum number of placeables which can be expanded in a single call to
Expand Down Expand Up @@ -187,8 +187,8 @@ function resolveVariableReference(
case "number":
return new FluentNumber(arg);
case "object":
if (arg instanceof Date) {
return new FluentDateTime(arg.getTime());
if (FluentDateTime.supportsValue(arg)) {
return new FluentDateTime(arg);
}
// eslint-disable-next-line no-fallthrough
default:
Expand Down
3 changes: 2 additions & 1 deletion fluent-bundle/src/scope.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FluentBundle, FluentVariable } from "./bundle.js";
import { FluentBundle } from "./bundle.js";
import { ComplexPattern } from "./ast.js";
import { FluentVariable } from "./types.js";

export class Scope {
/** The bundle for which the given resolution is happening. */
Expand Down
103 changes: 97 additions & 6 deletions fluent-bundle/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { Scope } from "./scope.js";

// Temporary workaround to support environments without Temporal
// Replace with Temporal.* types once they are provided by TypeScript
// In addition to this minimal interface, these objects are also expected
// to be supported by Intl.DateTimeFormat
interface TemporalObject {
epochMilliseconds?: number;
toZonedDateTime?(timeZone: string): { epochMilliseconds: number };
calendarId?: string;
toString(): string;
}

export type FluentValue = FluentType<unknown> | string;

export type FluentVariable =
| FluentValue
| TemporalObject
| string
| number
| Date;

export type FluentFunction = (
positional: Array<FluentValue>,
named: Record<string, FluentValue>
Expand Down Expand Up @@ -104,37 +122,110 @@ export class FluentNumber extends FluentType<number> {
/**
* A `FluentType` representing a date and time.
*
* A `FluentDateTime` instance stores the number value of the date it
* represents, as a numerical timestamp in milliseconds. It may also store an
* A `FluentDateTime` instance stores a Date object, Temporal object, or a number
* as a numerical timestamp in milliseconds. It may also store an
* option bag of options which will be passed to `Intl.DateTimeFormat` when the
* `FluentDateTime` is formatted to a string.
*/
export class FluentDateTime extends FluentType<number> {
export class FluentDateTime extends FluentType<
| number
| Date
| TemporalObject
> {
/** Options passed to `Intl.DateTimeFormat`. */
public opts: Intl.DateTimeFormatOptions;

static supportsValue(value: unknown): value is ConstructorParameters<typeof FluentDateTime>[0] {
if (typeof value === "number") return true;
if (value instanceof Date) return true;
if (value instanceof FluentType) return FluentDateTime.supportsValue(value.valueOf());
// Temporary workaround to support environments without Temporal
if ('Temporal' in globalThis) {
// for TypeScript, which doesn't know about Temporal yet
const _Temporal = (
globalThis as unknown as { Temporal: Record<string, () => unknown> }
).Temporal;
if (
value instanceof _Temporal.Instant ||
value instanceof _Temporal.PlainDateTime ||
value instanceof _Temporal.PlainDate ||
value instanceof _Temporal.PlainMonthDay ||
value instanceof _Temporal.PlainTime ||
value instanceof _Temporal.PlainYearMonth
) {
return true;
}
}
return false
}

/**
* Create an instance of `FluentDateTime` with options to the
* `Intl.DateTimeFormat` constructor.
*
* @param value The number value of this `FluentDateTime`, in milliseconds.
* @param opts Options which will be passed to `Intl.DateTimeFormat`.
*/
constructor(value: number, opts: Intl.DateTimeFormatOptions = {}) {
constructor(
value:
| number
| Date
| TemporalObject
| FluentDateTime
| FluentType<number>,
opts: Intl.DateTimeFormatOptions = {}
) {
// unwrap any FluentType value, but only retain the opts from FluentDateTime
if (value instanceof FluentDateTime) {
opts = { ...value.opts, ...opts };
value = value.value;
} else if (value instanceof FluentType) {
value = value.valueOf();
}

// Intl.DateTimeFormat defaults to gregorian calendar, but Temporal defaults to iso8601
if (typeof value === "object" && 'calendarId' in value && opts.calendar === undefined) {
opts = { ...opts, calendar: value.calendarId };
}

super(value);
this.opts = opts;
}

/**
* Convert this `FluentDateTime` to a number.
* Note that this isn't always possible due to the nature of Temporal objects.
* In such cases, a TypeError will be thrown.
*/
toNumber(): number {
const value = this.value;
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();

if ('epochMilliseconds' in value) {
return value.epochMilliseconds as number;
}

if ('toZonedDateTime' in value) {
return value.toZonedDateTime!("UTC").epochMilliseconds;
}

throw new TypeError("Unwrapping a non-number value as a number");
}

/**
* Format this `FluentDateTime` to a string.
*/
toString(scope: Scope): string {
try {
const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts);
return dtf.format(this.value);
return dtf.format(this.value as Parameters<Intl.DateTimeFormat["format"]>[0]);
} catch (err) {
scope.reportError(err);
return new Date(this.value).toISOString();
if (typeof this.value === "number" || this.value instanceof Date) {
return new Date(this.value).toISOString();
}
return this.value.toString();
}
}
}
Loading