diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c2910..0e77596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +4.3.0 - Unreleased +===================== + +* Add opt-in [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) support via `toml.parse(input, { useTemporal: true })`, mapping offset date-times to `Temporal.ZonedDateTime` and local date-times/dates/times to `Temporal.PlainDateTime`/`PlainDate`/`PlainTime`. An implementation can be supplied via the `temporal` option on runtimes without a `Temporal` global. ([#69](https://github.com/BinaryMuse/toml-node/issues/69)) + 4.2.0 - July 13 2026 ===================== diff --git a/README.md b/README.md index 1860a60..7d87edd 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,52 @@ typeof data.ld // "string" typeof data.lt // "string" ``` +#### Temporal Support + +Pass `useTemporal: true` to have date/time values returned as +[Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) +objects instead: + +| TOML type | Returned as | +| ---------------- | ------------------------- | +| Offset date-time | `Temporal.ZonedDateTime` | +| Local date-time | `Temporal.PlainDateTime` | +| Local date | `Temporal.PlainDate` | +| Local time | `Temporal.PlainTime` | + +```javascript +const data = toml.parse(` +odt = 1979-05-27T00:32:00-07:00 +ldt = 1979-05-27T07:32:00 +ld = 1979-05-27 +lt = 07:32:00 +`, { useTemporal: true }); + +data.odt.toString() // "1979-05-27T00:32:00-07:00[-07:00]" +data.ldt.toString() // "1979-05-27T07:32:00" +data.ld.toString() // "1979-05-27" +data.lt.toString() // "07:32:00" +``` + +Offset date-times become `Temporal.ZonedDateTime` values whose time zone is +the original UTC offset (`Z` maps to the `UTC` time zone), so the offset +written in the TOML document is preserved — unlike the default `Date` +representation, which loses it. Fractional seconds beyond nanosecond +precision are truncated, as permitted by the TOML spec. + +`useTemporal` requires a runtime with the `Temporal` global. On runtimes +that don't provide it yet, pass an implementation such as +[`@js-temporal/polyfill`](https://www.npmjs.com/package/@js-temporal/polyfill) +via the `temporal` option: + +```javascript +const { Temporal } = require('@js-temporal/polyfill'); +const data = toml.parse(someTomlString, { useTemporal: true, temporal: Temporal }); +``` + +Once `Temporal` is broadly available, Temporal output is expected to become +the default behavior in a future major version. + ### Special Float Values `inf` and `nan` are returned as JavaScript `Infinity` and `NaN`: diff --git a/index.d.ts b/index.d.ts index d5e9723..357cb31 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,16 @@ declare module 'toml' { + /** + * A minimal structural subset of the `Temporal` global, used to supply an + * alternate implementation (e.g. from `@js-temporal/polyfill`) on runtimes + * without native Temporal support. + */ + export interface TemporalLike { + ZonedDateTime: { from(item: string): any }; + PlainDateTime: { from(item: string): any }; + PlainDate: { from(item: string): any }; + PlainTime: { from(item: string): any }; + } + export interface ParseOptions { /** * Maximum nesting depth for arrays and inline tables. Parsing input nested @@ -6,6 +18,26 @@ declare module 'toml' { * Defaults to 500. */ maxDepth?: number; + + /** + * When true, date/time values are returned as Temporal objects instead of + * the default representations: + * + * - Offset date-time: `Temporal.ZonedDateTime` (instead of `Date`) + * - Local date-time: `Temporal.PlainDateTime` (instead of `string`) + * - Local date: `Temporal.PlainDate` (instead of `string`) + * - Local time: `Temporal.PlainTime` (instead of `string`) + * + * Requires a global `Temporal` object, or an implementation passed via + * the `temporal` option. + */ + useTemporal?: boolean; + + /** + * The Temporal implementation to use when `useTemporal` is set. Defaults + * to the global `Temporal` object if one is available. + */ + temporal?: TemporalLike; } export function parse(input: string, options?: ParseOptions): any; diff --git a/index.js b/index.js index 2c6e21e..8dc21ba 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,6 @@ module.exports = { parse: function(input, options) { var str = input.toString(); var nodes = parser.parse(str, options); - return compiler.compile(nodes, str); + return compiler.compile(nodes, str, options); } }; diff --git a/lib/compiler.js b/lib/compiler.js index e03c99c..60afd6f 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -1,5 +1,18 @@ "use strict"; -function compile(nodes, inputText) { +function compile(nodes, inputText, options) { + options = options || {}; + var temporal = null; + if (options.useTemporal) { + temporal = options.temporal || (typeof Temporal !== "undefined" ? Temporal : null); + if (!temporal) { + throw new Error( + "The `useTemporal` option was set, but no Temporal implementation is available. " + + "Use a runtime with global `Temporal` support, or provide an implementation " + + "(e.g. from the `@js-temporal/polyfill` package) via the `temporal` option." + ); + } + } + var assignedPaths = new Set(); var valueAssignments = new Set(); var explicitTablePaths = new Set(); @@ -93,9 +106,28 @@ function compile(nodes, inputText) { return reduceArray(node.value); } else if (node.type === "InlineTable") { return reduceInlineTableNode(node.value); - } else { - return node.value; + } else if (temporal) { + switch (node.type) { + case "Date": + return temporal.ZonedDateTime.from( + truncateFractionalSeconds(node.raw) + node.tz + + "[" + (node.tz === "Z" ? "UTC" : node.tz) + "]" + ); + case "LocalDateTime": + return temporal.PlainDateTime.from(truncateFractionalSeconds(node.value)); + case "LocalDate": + return temporal.PlainDate.from(node.value); + case "LocalTime": + return temporal.PlainTime.from(truncateFractionalSeconds(node.value)); + } } + return node.value; + } + + // TOML allows arbitrary fractional-second precision (implementations may + // truncate); Temporal rejects more than 9 digits (nanoseconds). + function truncateFractionalSeconds(str) { + return str.replace(/\.(\d{9})\d+/, ".$1"); } function reduceInlineTableNode(values) { diff --git a/lib/parser.js b/lib/parser.js index 35cb268..ef33858 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -348,7 +348,7 @@ function peg$parse(input, options) { function peg$f60(t, frac) { return frac ? t + frac : t } function peg$f61(t) { return t + ':00' } function peg$f62() { return "Z" } - function peg$f63(d, t, o) { var off = offset(); validateDate(d, off); validateTime(t, off); validateOffset(o, off); return node('Date', new Date(d + "T" + t + o), off) } + function peg$f63(d, t, o) { var off = offset(); validateDate(d, off); validateTime(t, off); validateOffset(o, off); var n = node('Date', new Date(d + "T" + t + o), off); n.raw = d + "T" + t; n.tz = o; return n } function peg$f64(d, t) { var off = offset(); validateDate(d, off); validateTime(t, off); return node('LocalDateTime', d + "T" + t, off) } function peg$f65(d) { var off = offset(); validateDate(d, off); return node('LocalDate', d, off) } function peg$f66(t) { var off = offset(); validateTime(t, off); return node('LocalTime', t, off) } diff --git a/package-lock.json b/package-lock.json index 85a18fc..0d97bcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,26 @@ "version": "4.2.0", "license": "MIT", "devDependencies": { + "@js-temporal/polyfill": "^0.5.1", "peggy": "^5.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@peggyjs/from-mem": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@peggyjs/from-mem/-/from-mem-3.1.3.tgz", @@ -28,6 +42,13 @@ "node": ">=20.8" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/peggy": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/peggy/-/peggy-5.1.0.tgz", diff --git a/package.json b/package.json index 53049f8..5fad35a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "author": "Michelle Tilley ", "license": "MIT", "devDependencies": { + "@js-temporal/polyfill": "^0.5.1", "peggy": "^5.1.0" } } diff --git a/src/toml.pegjs b/src/toml.pegjs index 61be02b..9ad7da1 100644 --- a/src/toml.pegjs +++ b/src/toml.pegjs @@ -331,7 +331,7 @@ offset / $(sign:[+-] DIGIT DIGIT ':' DIGIT DIGIT) datetime - = d:date_part datetime_delim t:time_part o:offset { var off = offset(); validateDate(d, off); validateTime(t, off); validateOffset(o, off); return node('Date', new Date(d + "T" + t + o), off) } + = d:date_part datetime_delim t:time_part o:offset { var off = offset(); validateDate(d, off); validateTime(t, off); validateOffset(o, off); var n = node('Date', new Date(d + "T" + t + o), off); n.raw = d + "T" + t; n.tz = o; return n } / d:date_part datetime_delim t:time_part { var off = offset(); validateDate(d, off); validateTime(t, off); return node('LocalDateTime', d + "T" + t, off) } / d:date_part !datetime_delim { var off = offset(); validateDate(d, off); return node('LocalDate', d, off) } / t:time_part { var off = offset(); validateTime(t, off); return node('LocalTime', t, off) } diff --git a/test/test_toml.js b/test/test_toml.js index 06f0861..799f782 100644 --- a/test/test_toml.js +++ b/test/test_toml.js @@ -340,6 +340,119 @@ describe("datetimes", function () { }); }); +describe("temporal support", function () { + var Temporal = require("@js-temporal/polyfill").Temporal; + + function parseTemporal(str) { + return toml.parse(str, { useTemporal: true, temporal: Temporal }); + } + + it("parses offset date-times as Temporal.ZonedDateTime", function () { + var result = parseTemporal("a = 1979-05-27T07:32:00Z"); + assert.ok(result.a instanceof Temporal.ZonedDateTime); + assert.strictEqual(result.a.toString(), "1979-05-27T07:32:00+00:00[UTC]"); + assert.strictEqual( + result.a.epochMilliseconds, + new Date("1979-05-27T07:32:00Z").getTime() + ); + }); + + it("preserves the offset of offset date-times", function () { + var result = parseTemporal( + "a = 1979-05-27T00:32:00-07:00\nb = 1979-05-27T07:32:00+02:00" + ); + assert.strictEqual(result.a.toString(), "1979-05-27T00:32:00-07:00[-07:00]"); + assert.strictEqual(result.b.toString(), "1979-05-27T07:32:00+02:00[+02:00]"); + }); + + it("parses offset date-times with lowercase delimiters and space delimiter", function () { + var result = parseTemporal( + "a = 1979-05-27t07:32:00z\nb = 1979-05-27 07:32:00Z" + ); + assert.strictEqual(result.a.toString(), "1979-05-27T07:32:00+00:00[UTC]"); + assert.strictEqual(result.b.toString(), "1979-05-27T07:32:00+00:00[UTC]"); + }); + + it("parses local date-times as Temporal.PlainDateTime", function () { + var result = parseTemporal("a = 1979-05-27T07:32:00\nb = 1979-05-27T00:32:00.999999"); + assert.ok(result.a instanceof Temporal.PlainDateTime); + assert.strictEqual(result.a.toString(), "1979-05-27T07:32:00"); + assert.strictEqual(result.b.toString(), "1979-05-27T00:32:00.999999"); + }); + + it("parses local dates as Temporal.PlainDate", function () { + var result = parseTemporal("a = 1979-05-27"); + assert.ok(result.a instanceof Temporal.PlainDate); + assert.strictEqual(result.a.toString(), "1979-05-27"); + }); + + it("parses local times as Temporal.PlainTime", function () { + var result = parseTemporal("a = 07:32:00\nb = 00:32:00.999999"); + assert.ok(result.a instanceof Temporal.PlainTime); + assert.strictEqual(result.a.toString(), "07:32:00"); + assert.strictEqual(result.b.toString(), "00:32:00.999999"); + }); + + it("parses times with omitted seconds", function () { + var result = parseTemporal("a = 07:32\nb = 1979-05-27T07:32"); + assert.strictEqual(result.a.toString(), "07:32:00"); + assert.strictEqual(result.b.toString(), "1979-05-27T07:32:00"); + }); + + it("truncates fractional seconds beyond nanosecond precision", function () { + var result = parseTemporal( + "a = 07:32:00.123456789999\n" + + "b = 1979-05-27T07:32:00.123456789999\n" + + "c = 1979-05-27T07:32:00.123456789999-07:00" + ); + assert.strictEqual(result.a.toString(), "07:32:00.123456789"); + assert.strictEqual(result.b.toString(), "1979-05-27T07:32:00.123456789"); + assert.strictEqual( + result.c.toString(), + "1979-05-27T07:32:00.123456789-07:00[-07:00]" + ); + }); + + it("converts date-times inside arrays and inline tables", function () { + var result = parseTemporal( + "arr = [ 1979-05-27, 07:32:00 ]\ninline = { when = 1979-05-27T07:32:00Z }" + ); + assert.ok(result.arr[0] instanceof Temporal.PlainDate); + assert.ok(result.arr[1] instanceof Temporal.PlainTime); + assert.ok(result.inline.when instanceof Temporal.ZonedDateTime); + }); + + it("does not affect parsing when useTemporal is not set", function () { + var result = toml.parse("a = 1979-05-27T07:32:00Z\nb = 1979-05-27\nc = 07:32:00"); + assert.ok(result.a instanceof Date); + assert.strictEqual(result.b, "1979-05-27"); + assert.strictEqual(result.c, "07:32:00"); + }); + + it("uses the global Temporal object when no implementation is passed", function () { + var hadGlobal = "Temporal" in globalThis; + var previous = globalThis.Temporal; + globalThis.Temporal = Temporal; + try { + var result = toml.parse("a = 1979-05-27", { useTemporal: true }); + assert.ok(result.a instanceof Temporal.PlainDate); + } finally { + if (hadGlobal) globalThis.Temporal = previous; + else delete globalThis.Temporal; + } + }); + + it( + "throws a helpful error when no Temporal implementation is available", + { skip: typeof globalThis.Temporal !== "undefined" }, + function () { + assert.throws(function () { + toml.parse("a = 1979-05-27", { useTemporal: true }); + }, /no Temporal implementation is available/); + } + ); +}); + describe("quoted keys", function () { it("simple quoted key", function () { parsesToml('["ʞ"]\na = 1', { ʞ: { a: 1 } });