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..9c6316a2 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -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 | string; +export type FluentVariable = + | FluentValue + | TemporalObject + | string + | number + | Date; + export type FluentFunction = ( positional: Array, named: Record @@ -104,15 +122,43 @@ 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 + | TemporalObject +> { /** Options passed to `Intl.DateTimeFormat`. */ public opts: Intl.DateTimeFormatOptions; + 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()); + // 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 ( + 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. @@ -120,21 +166,66 @@ 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 + | TemporalObject + | 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(); + } + + // 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[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(); } } } diff --git a/fluent-bundle/test/temporal_test.js b/fluent-bundle/test/temporal_test.js new file mode 100644 index 00000000..9e0213ea --- /dev/null +++ b/fluent-bundle/test/temporal_test.js @@ -0,0 +1,205 @@ +"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; + + // 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); + 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"), arg.toLocaleString()); + }); + + test("run through DATETIME()", function () { + assert.strictEqual(msg("dt"), arg.toLocaleString()); + }); + + 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, { month: "long" }); + assert.strictEqual(msg("dt"), "January"); + }); + + test("can be converted to a number", function () { + arg = new FluentDateTime(arg); + assert.strictEqual(arg.toNumber(), 0); + }); + }); + + 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("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, { month: "long" }); + assert.strictEqual(msg("dt"), "January"); + }); + + 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("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); + }); + }); +}); 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",