From f4c5217157a55d02fe3bbc39d9112cdaef473f1b Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Sun, 2 Mar 2025 11:00:00 +0100 Subject: [PATCH 1/8] Implement support for Temporal objects (except Temporal.Duration). --- fluent-bundle/package.json | 3 +- fluent-bundle/src/builtins.ts | 15 +- fluent-bundle/src/bundle.ts | 3 +- fluent-bundle/src/index.ts | 3 +- fluent-bundle/src/resolver.ts | 6 +- fluent-bundle/src/scope.ts | 3 +- fluent-bundle/src/types.ts | 122 ++++++++++++- fluent-bundle/test/temporal_test.js | 269 ++++++++++++++++++++++++++++ package-lock.json | 20 ++- 9 files changed, 417 insertions(+), 27 deletions(-) create mode 100644 fluent-bundle/test/temporal_test.js diff --git a/fluent-bundle/package.json b/fluent-bundle/package.json index e1c4bc53..6fcdf02d 100644 --- a/fluent-bundle/package.json +++ b/fluent-bundle/package.json @@ -49,6 +49,7 @@ "npm": ">=7.0.0" }, "devDependencies": { - "@fluent/dedent": "^0.5.0" + "@fluent/dedent": "^0.5.0", + "temporal-polyfill": "^0.2.5" } } diff --git a/fluent-bundle/src/builtins.ts b/fluent-bundle/src/builtins.ts index 3cf0c31c..10de6588 100644 --- a/fluent-bundle/src/builtins.ts +++ b/fluent-bundle/src/builtins.ts @@ -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), }); } @@ -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"); diff --git a/fluent-bundle/src/bundle.ts b/fluent-bundle/src/bundle.ts index 3c0f7549..3ec8dcd8 100644 --- a/fluent-bundle/src/bundle.ts +++ b/fluent-bundle/src/bundle.ts @@ -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 diff --git a/fluent-bundle/src/index.ts b/fluent-bundle/src/index.ts index 66e59124..369c4c96 100644 --- a/fluent-bundle/src/index.ts +++ b/fluent-bundle/src/index.ts @@ -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, diff --git a/fluent-bundle/src/resolver.ts b/fluent-bundle/src/resolver.ts index 2e849bc1..0779f7f2 100644 --- a/fluent-bundle/src/resolver.ts +++ b/fluent-bundle/src/resolver.ts @@ -30,6 +30,7 @@ import { FluentNone, FluentNumber, FluentDateTime, + FluentVariable, } from "./types.js"; import { Scope } from "./scope.js"; import { @@ -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 @@ -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: diff --git a/fluent-bundle/src/scope.ts b/fluent-bundle/src/scope.ts index 32218939..4eaba133 100644 --- a/fluent-bundle/src/scope.ts +++ b/fluent-bundle/src/scope.ts @@ -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. */ diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index 16d062dd..cd6a13d0 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -1,7 +1,20 @@ import { Scope } from "./scope.js"; +import type { Temporal } from "temporal-polyfill"; export type FluentValue = FluentType | string; +export type FluentVariable = + | FluentValue + | Temporal.Instant + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainTime + | Temporal.PlainYearMonth + | Temporal.PlainMonthDay + | Temporal.ZonedDateTime + | string + | number; + export type FluentFunction = ( positional: Array, named: Record @@ -104,15 +117,47 @@ export class FluentNumber extends FluentType { /** * 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 { +export class FluentDateTime extends FluentType< + | number + | Date + | Temporal.Instant + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainMonthDay + | Temporal.PlainTime + | Temporal.PlainYearMonth + | Temporal.ZonedDateTime +> { /** Options passed to `Intl.DateTimeFormat`. */ public opts: Intl.DateTimeFormatOptions; + static supportsValue(value: any): value is ConstructorParameters[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) { + if ( + // @ts-ignore + value instanceof Temporal.Instant || // @ts-ignore + value instanceof Temporal.PlainDateTime || // @ts-ignore + value instanceof Temporal.PlainDate || // @ts-ignore + value instanceof Temporal.PlainMonthDay || // @ts-ignore + value instanceof Temporal.PlainTime || // @ts-ignore + value instanceof Temporal.PlainYearMonth || // @ts-ignore + value instanceof Temporal.ZonedDateTime + ) { + return true; + } + } + return false + } + /** * Create an instance of `FluentDateTime` with options to the * `Intl.DateTimeFormat` constructor. @@ -120,21 +165,86 @@ export class FluentDateTime extends FluentType { * @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 + | Temporal.Instant + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainMonthDay + | Temporal.PlainTime + | Temporal.PlainYearMonth + | Temporal.ZonedDateTime + | FluentDateTime + | FluentType, + 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(); + } + + if (typeof value === "object") { + // Intl.DateTimeFormat defaults to gregorian calendar, but Temporal defaults to iso8601 + if ('calendarId' in value) { + if (opts.calendar === undefined) { + opts = { ...opts, calendar: value.calendarId }; + } else if (opts.calendar !== value.calendarId && 'withCalendar' in value) { + value = value.withCalendar(opts.calendar); + } + } + + // Temporal.ZonedDateTime is timezone aware + if ('timeZoneId' in value) { + if (opts.timeZone === undefined) { + opts = { ...opts, timeZone: value.timeZoneId }; + } else if (opts.timeZone !== value.timeZoneId && 'withTimeZone' in value) { + value = value.withTimeZone(opts.timeZone); + } + } + + // Temporal.ZonedDateTime cannot be formatted directly + if ('toInstant' in value) { + value = value.toInstant(); + } + } + 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; + if ('toZonedDateTime' in value) return (value as Temporal.PlainDateTime).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[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(); + } else { + return this.value.toString(); + } } } } diff --git a/fluent-bundle/test/temporal_test.js b/fluent-bundle/test/temporal_test.js new file mode 100644 index 00000000..a1dd4a73 --- /dev/null +++ b/fluent-bundle/test/temporal_test.js @@ -0,0 +1,269 @@ +"use strict"; + +import assert from "assert"; +import ftl from "@fluent/dedent"; + +import { FluentBundle } from "../esm/bundle.js"; +import { FluentResource } from "../esm/resource.js"; +import { FluentDateTime } from "../esm/types.js"; + +suite("Temporal support", function () { + let bundle, arg; + + function msg(id, errors = undefined) { + const errs = []; + const msg = bundle.getMessage(id); + const res = bundle.formatPattern(msg.value, { arg }, errors || errs); + if (errs.length > 0) { assert.fail(errs[0].message); } + return res; + } + + suiteSetup(async function () { + if (typeof Temporal === "undefined") { + await import("temporal-polyfill/global"); + } + + bundle = new FluentBundle("en-US", { useIsolating: false }); + bundle.addResource( + new FluentResource(ftl` + direct = { $arg } + dt = { DATETIME($arg) } + month = { DATETIME($arg, month: "long", year: "numeric") } + timezone = { DATETIME($arg, timeZoneName: "shortGeneric") } + `) + ); + }); + + suite("Temporal.Instant", function () { + setup(function () { + arg = Temporal.Instant.from("1970-01-01T00:00:00Z"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1/1/1970, 1:00:00 AM"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1/1/1970, 1:00:00 AM"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "January 1970"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "12/31/1969, 7:00:00 PM"); + assert.strictEqual(msg("timezone"), "12/31/1969, 7:00:00 PM ET"); + }); + + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); + }); + + suite("Temporal.PlainDate (gregory)", function () { + setup(function () { + arg = Temporal.PlainDate.from("1970-01-01[u-ca=gregory]"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1/1/1970"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1/1/1970"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "January 1970"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { calendar: "iso8601" }); + assert.strictEqual(msg("dt"), "1970-01-01"); + }); + + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); + }); + + suite("Temporal.PlainDate (iso8601)", function () { + setup(function () { + arg = Temporal.PlainDate.from("1970-01-01[u-ca=iso8601]"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1970-01-01"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1970-01-01"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "1970 January"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { calendar: "gregory" }); + assert.strictEqual(msg("dt"), "1/1/1970"); + }); + + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); + }); + + suite("Temporal.PlainDateTime", function () { + setup(function () { + arg = Temporal.PlainDateTime.from("1970-01-01T00:00:00[u-ca=gregory]"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1/1/1970, 12:00:00 AM"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1/1/1970, 12:00:00 AM"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "January 1970"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "1/1/1970, 12:00:00 AM"); + }); + }); + + suite("Temporal.PlainTime", function () { + setup(function () { + arg = Temporal.PlainTime.from("00:00:00"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "12:00:00 AM"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "12:00:00 AM"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "12:00:00 AM"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "12:00:00 AM"); + }); + + test("cannot be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.throws(() => arg.toNumber(), TypeError); + }); + }); + + suite("PlainYearMonth (gregory)", function () { + setup(function () { + arg = Temporal.PlainYearMonth.from({ + year: 1970, + month: 1, + calendar: "gregory" + }); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1/1970"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1/1970"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "January 1970"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "1/1970"); + }); + + test("cannot be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.throws(() => arg.toNumber(), TypeError); + }); + }); + + suite("Temporal.ZonedDateTime (gregory)", function () { + setup(function () { + arg = Temporal.ZonedDateTime.from("1970-01-01T00:00:00Z[UTC][u-ca=gregory]"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1/1/1970, 12:00:00 AM"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1/1/1970, 12:00:00 AM"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "January 1970"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "12/31/1969, 7:00:00 PM"); + assert.strictEqual(msg("timezone"), "12/31/1969, 7:00:00 PM ET"); + }); + + test("respects timeZoneId", function () { + assert.strictEqual(msg("timezone"), "1/1/1970, 12:00:00 AM GMT"); + arg = arg.withTimeZone("America/New_York"); + assert.strictEqual(msg("timezone"), "12/31/1969, 7:00:00 PM ET"); + }); + + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); + }); + + suite("Temporal.ZonedDateTime (iso8601)", function () { + setup(function () { + arg = Temporal.ZonedDateTime.from("1970-01-01T00:00:00Z[UTC][u-ca=iso8601]"); + }); + + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1970-01-01, 12:00:00 AM"); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1970-01-01, 12:00:00 AM"); + }); + + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "1970 January"); + }); + + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); + assert.strictEqual(msg("dt"), "1969-12-31, 7:00:00 PM"); + assert.strictEqual(msg("timezone"), "1969-12-31, 7:00:00 PM ET"); + }); + + test("respects timeZoneId", function () { + assert.strictEqual(msg("timezone"), "1970-01-01, 12:00:00 AM GMT"); + arg = arg.withTimeZone("America/New_York"); + assert.strictEqual(msg("timezone"), "1969-12-31, 7:00:00 PM ET"); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 8aa3a975..4732ef67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,8 @@ "version": "0.18.0", "license": "Apache-2.0", "devDependencies": { - "@fluent/dedent": "^0.5.0" + "@fluent/dedent": "^0.5.0", + "temporal-polyfill": "^0.2.5" }, "engines": { "node": ">=18.0.0", @@ -8304,6 +8305,23 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/temporal-polyfill": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.2.5.tgz", + "integrity": "sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "temporal-spec": "^0.2.4" + } + }, + "node_modules/temporal-spec": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.2.4.tgz", + "integrity": "sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", From f0547e5a275a862367ebf89e932c7ea4894ccdb7 Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Mon, 3 Mar 2025 09:16:18 +0100 Subject: [PATCH 2/8] Update fluent-bundle/src/types.ts Co-authored-by: Eemeli Aro --- fluent-bundle/src/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index cd6a13d0..6bcfb109 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -13,7 +13,8 @@ export type FluentVariable = | Temporal.PlainMonthDay | Temporal.ZonedDateTime | string - | number; + | number + | Date; export type FluentFunction = ( positional: Array, From c8cdde156d5527904741c6250e5e1f3b62511dea Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 7 Mar 2025 09:20:55 +0100 Subject: [PATCH 3/8] Temporal: Remove support for ZonedDateTime, no longer convert calendars --- fluent-bundle/src/types.ts | 32 ++----------- fluent-bundle/test/temporal_test.js | 73 ++--------------------------- 2 files changed, 8 insertions(+), 97 deletions(-) diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index 6bcfb109..84ea6cca 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -11,7 +11,6 @@ export type FluentVariable = | Temporal.PlainTime | Temporal.PlainYearMonth | Temporal.PlainMonthDay - | Temporal.ZonedDateTime | string | number | Date; @@ -132,7 +131,6 @@ export class FluentDateTime extends FluentType< | Temporal.PlainMonthDay | Temporal.PlainTime | Temporal.PlainYearMonth - | Temporal.ZonedDateTime > { /** Options passed to `Intl.DateTimeFormat`. */ public opts: Intl.DateTimeFormatOptions; @@ -150,8 +148,7 @@ export class FluentDateTime extends FluentType< value instanceof Temporal.PlainDate || // @ts-ignore value instanceof Temporal.PlainMonthDay || // @ts-ignore value instanceof Temporal.PlainTime || // @ts-ignore - value instanceof Temporal.PlainYearMonth || // @ts-ignore - value instanceof Temporal.ZonedDateTime + value instanceof Temporal.PlainYearMonth ) { return true; } @@ -176,7 +173,6 @@ export class FluentDateTime extends FluentType< | Temporal.PlainMonthDay | Temporal.PlainTime | Temporal.PlainYearMonth - | Temporal.ZonedDateTime | FluentDateTime | FluentType, opts: Intl.DateTimeFormatOptions = {} @@ -189,29 +185,9 @@ export class FluentDateTime extends FluentType< value = value.valueOf(); } - if (typeof value === "object") { - // Intl.DateTimeFormat defaults to gregorian calendar, but Temporal defaults to iso8601 - if ('calendarId' in value) { - if (opts.calendar === undefined) { - opts = { ...opts, calendar: value.calendarId }; - } else if (opts.calendar !== value.calendarId && 'withCalendar' in value) { - value = value.withCalendar(opts.calendar); - } - } - - // Temporal.ZonedDateTime is timezone aware - if ('timeZoneId' in value) { - if (opts.timeZone === undefined) { - opts = { ...opts, timeZone: value.timeZoneId }; - } else if (opts.timeZone !== value.timeZoneId && 'withTimeZone' in value) { - value = value.withTimeZone(opts.timeZone); - } - } - - // Temporal.ZonedDateTime cannot be formatted directly - if ('toInstant' in value) { - value = value.toInstant(); - } + // 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); diff --git a/fluent-bundle/test/temporal_test.js b/fluent-bundle/test/temporal_test.js index a1dd4a73..197daaae 100644 --- a/fluent-bundle/test/temporal_test.js +++ b/fluent-bundle/test/temporal_test.js @@ -81,8 +81,8 @@ suite("Temporal support", function () { }); test("wrapped in FluentDateTime", function () { - arg = new FluentDateTime(arg, { calendar: "iso8601" }); - assert.strictEqual(msg("dt"), "1970-01-01"); + arg = new FluentDateTime(arg, { month: "long" }); + assert.strictEqual(msg("dt"), "January"); }); test("can be converted to a number", function () { @@ -109,8 +109,8 @@ suite("Temporal support", function () { }); test("wrapped in FluentDateTime", function () { - arg = new FluentDateTime(arg, { calendar: "gregory" }); - assert.strictEqual(msg("dt"), "1/1/1970"); + arg = new FluentDateTime(arg, { month: "long" }); + assert.strictEqual(msg("dt"), "January"); }); test("can be converted to a number", function () { @@ -201,69 +201,4 @@ suite("Temporal support", function () { assert.throws(() => arg.toNumber(), TypeError); }); }); - - suite("Temporal.ZonedDateTime (gregory)", function () { - setup(function () { - arg = Temporal.ZonedDateTime.from("1970-01-01T00:00:00Z[UTC][u-ca=gregory]"); - }); - - test("direct interpolation", function () { - assert.strictEqual(msg("direct"), "1/1/1970, 12:00:00 AM"); - }); - - test("run through DATETIME()", function () { - assert.strictEqual(msg("dt"), "1/1/1970, 12:00:00 AM"); - }); - - test("run through DATETIME() with month option", function () { - assert.strictEqual(msg("month"), "January 1970"); - }); - - test("wrapped in FluentDateTime", function () { - arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); - assert.strictEqual(msg("dt"), "12/31/1969, 7:00:00 PM"); - assert.strictEqual(msg("timezone"), "12/31/1969, 7:00:00 PM ET"); - }); - - test("respects timeZoneId", function () { - assert.strictEqual(msg("timezone"), "1/1/1970, 12:00:00 AM GMT"); - arg = arg.withTimeZone("America/New_York"); - assert.strictEqual(msg("timezone"), "12/31/1969, 7:00:00 PM ET"); - }); - - test("can be converted to a number", function () { - arg = new FluentDateTime(arg); - assert.strictEqual(arg.toNumber(), 0); - }); - }); - - suite("Temporal.ZonedDateTime (iso8601)", function () { - setup(function () { - arg = Temporal.ZonedDateTime.from("1970-01-01T00:00:00Z[UTC][u-ca=iso8601]"); - }); - - test("direct interpolation", function () { - assert.strictEqual(msg("direct"), "1970-01-01, 12:00:00 AM"); - }); - - test("run through DATETIME()", function () { - assert.strictEqual(msg("dt"), "1970-01-01, 12:00:00 AM"); - }); - - test("run through DATETIME() with month option", function () { - assert.strictEqual(msg("month"), "1970 January"); - }); - - test("wrapped in FluentDateTime", function () { - arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); - assert.strictEqual(msg("dt"), "1969-12-31, 7:00:00 PM"); - assert.strictEqual(msg("timezone"), "1969-12-31, 7:00:00 PM ET"); - }); - - test("respects timeZoneId", function () { - assert.strictEqual(msg("timezone"), "1970-01-01, 12:00:00 AM GMT"); - arg = arg.withTimeZone("America/New_York"); - assert.strictEqual(msg("timezone"), "1969-12-31, 7:00:00 PM ET"); - }); - }); }); From b13244349b8cede3b51d09800859e9f93cd61b7c Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 7 Mar 2025 09:25:23 +0100 Subject: [PATCH 4/8] Fix supportsValue signature --- fluent-bundle/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index 84ea6cca..a2359f00 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -135,7 +135,7 @@ export class FluentDateTime extends FluentType< /** Options passed to `Intl.DateTimeFormat`. */ public opts: Intl.DateTimeFormatOptions; - static supportsValue(value: any): value is ConstructorParameters[0] { + static supportsValue(value: unknown): value is ConstructorParameters[0] { if (typeof value === "number") return true; if (value instanceof Date) return true; if (value instanceof FluentType) return FluentDateTime.supportsValue(value.valueOf()); From f304685b6a8a960b8246113d736511d37a5d85dc Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 7 Mar 2025 09:26:04 +0100 Subject: [PATCH 5/8] remove unnecessary else --- fluent-bundle/src/types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index a2359f00..1daa977d 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -219,9 +219,8 @@ export class FluentDateTime extends FluentType< scope.reportError(err); if (typeof this.value === "number" || this.value instanceof Date) { return new Date(this.value).toISOString(); - } else { - return this.value.toString(); } + return this.value.toString(); } } } From 6488b12e6409f1536e3486664a3d4c4b47a7e9a2 Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 7 Mar 2025 09:56:08 +0100 Subject: [PATCH 6/8] remove any reference of Temporal classes as types --- fluent-bundle/src/types.ts | 61 +++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index 1daa977d..9c6316a2 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -1,16 +1,21 @@ import { Scope } from "./scope.js"; -import type { Temporal } from "temporal-polyfill"; + +// 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 | string; export type FluentVariable = | FluentValue - | Temporal.Instant - | Temporal.PlainDateTime - | Temporal.PlainDate - | Temporal.PlainTime - | Temporal.PlainYearMonth - | Temporal.PlainMonthDay + | TemporalObject | string | number | Date; @@ -125,12 +130,7 @@ export class FluentNumber extends FluentType { export class FluentDateTime extends FluentType< | number | Date - | Temporal.Instant - | Temporal.PlainDateTime - | Temporal.PlainDate - | Temporal.PlainMonthDay - | Temporal.PlainTime - | Temporal.PlainYearMonth + | TemporalObject > { /** Options passed to `Intl.DateTimeFormat`. */ public opts: Intl.DateTimeFormatOptions; @@ -141,14 +141,17 @@ export class FluentDateTime extends FluentType< 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 unknown> } + ).Temporal; if ( - // @ts-ignore - value instanceof Temporal.Instant || // @ts-ignore - value instanceof Temporal.PlainDateTime || // @ts-ignore - value instanceof Temporal.PlainDate || // @ts-ignore - value instanceof Temporal.PlainMonthDay || // @ts-ignore - value instanceof Temporal.PlainTime || // @ts-ignore - value instanceof Temporal.PlainYearMonth + 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; } @@ -167,12 +170,7 @@ export class FluentDateTime extends FluentType< value: | number | Date - | Temporal.Instant - | Temporal.PlainDateTime - | Temporal.PlainDate - | Temporal.PlainMonthDay - | Temporal.PlainTime - | Temporal.PlainYearMonth + | TemporalObject | FluentDateTime | FluentType, opts: Intl.DateTimeFormatOptions = {} @@ -203,8 +201,15 @@ export class FluentDateTime extends FluentType< const value = this.value; if (typeof value === "number") return value; if (value instanceof Date) return value.getTime(); - if ('epochMilliseconds' in value) return value.epochMilliseconds; - if ('toZonedDateTime' in value) return (value as Temporal.PlainDateTime).toZonedDateTime("UTC").epochMilliseconds; + + 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"); } From 3571b6426adf1ba75508ddd6564ee4791252ecd5 Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 21 Mar 2025 13:05:13 +0100 Subject: [PATCH 7/8] make temporal tests local timezone independent --- fluent-bundle/test/temporal_test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fluent-bundle/test/temporal_test.js b/fluent-bundle/test/temporal_test.js index 197daaae..45ada1d1 100644 --- a/fluent-bundle/test/temporal_test.js +++ b/fluent-bundle/test/temporal_test.js @@ -40,11 +40,11 @@ suite("Temporal support", function () { }); test("direct interpolation", function () { - assert.strictEqual(msg("direct"), "1/1/1970, 1:00:00 AM"); + assert.strictEqual(msg("direct"), arg.toLocaleString()); }); test("run through DATETIME()", function () { - assert.strictEqual(msg("dt"), "1/1/1970, 1:00:00 AM"); + assert.strictEqual(msg("dt"), arg.toLocaleString()); }); test("run through DATETIME() with month option", function () { From 09989d8aac6941d7cc8241657f940b612dd2b80d Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Fri, 21 Mar 2025 17:59:51 +0100 Subject: [PATCH 8/8] fix tests for older Node.js versions --- fluent-bundle/test/temporal_test.js | 51 +++++++++++++++-------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/fluent-bundle/test/temporal_test.js b/fluent-bundle/test/temporal_test.js index 45ada1d1..9e0213ea 100644 --- a/fluent-bundle/test/temporal_test.js +++ b/fluent-bundle/test/temporal_test.js @@ -10,6 +10,9 @@ import { FluentDateTime } from "../esm/types.js"; suite("Temporal support", function () { let bundle, arg; + // Node.js prior to v20 does not support the iso8601 calendar + const supportIso8601 = new Intl.DateTimeFormat("en-US", { calendar: "iso8601" }).format(0) === "1970-01-01"; + function msg(id, errors = undefined) { const errs = []; const msg = bundle.getMessage(id); @@ -91,33 +94,35 @@ suite("Temporal support", function () { }); }); - suite("Temporal.PlainDate (iso8601)", function () { - setup(function () { - arg = Temporal.PlainDate.from("1970-01-01[u-ca=iso8601]"); - }); + if (supportIso8601) { + suite("Temporal.PlainDate (iso8601)", function () { + setup(function () { + arg = Temporal.PlainDate.from("1970-01-01[u-ca=iso8601]"); + }); - test("direct interpolation", function () { - assert.strictEqual(msg("direct"), "1970-01-01"); - }); + test("direct interpolation", function () { + assert.strictEqual(msg("direct"), "1970-01-01"); + }); - test("run through DATETIME()", function () { - assert.strictEqual(msg("dt"), "1970-01-01"); - }); + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), "1970-01-01"); + }); - test("run through DATETIME() with month option", function () { - assert.strictEqual(msg("month"), "1970 January"); - }); + test("run through DATETIME() with month option", function () { + assert.strictEqual(msg("month"), "1970 January"); + }); - test("wrapped in FluentDateTime", function () { - arg = new FluentDateTime(arg, { month: "long" }); - assert.strictEqual(msg("dt"), "January"); - }); + test("wrapped in FluentDateTime", function () { + arg = new FluentDateTime(arg, { month: "long" }); + assert.strictEqual(msg("dt"), "January"); + }); - test("can be converted to a number", function () { - arg = new FluentDateTime(arg); - assert.strictEqual(arg.toNumber(), 0); + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); }); - }); + } suite("Temporal.PlainDateTime", function () { setup(function () { @@ -155,10 +160,6 @@ suite("Temporal support", function () { assert.strictEqual(msg("dt"), "12:00:00 AM"); }); - test("run through DATETIME() with month option", function () { - assert.strictEqual(msg("month"), "12:00:00 AM"); - }); - test("wrapped in FluentDateTime", function () { arg = new FluentDateTime(arg, { timeZone: "America/New_York" }); assert.strictEqual(msg("dt"), "12:00:00 AM");