From f303230aea1129468e28ac859be22bf635f45c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 03:44:51 +0000 Subject: [PATCH] Add opt-in Temporal support for date/time values Add an optional second argument to toml.parse(). When useTemporal is set, date/time values are returned as their Temporal counterparts instead of the default representations: - Offset date-time -> Temporal.ZonedDateTime (was Date) - Local date-time -> Temporal.PlainDateTime (was string) - Local date -> Temporal.PlainDate (was string) - Local time -> Temporal.PlainTime (was string) Offset date-times keep the offset written in the document as the ZonedDateTime's time zone (Z maps to UTC), which the default Date representation cannot preserve. Fractional seconds beyond nanosecond precision are truncated, as permitted by the TOML spec. Runtimes without a global Temporal can supply an implementation (e.g. @js-temporal/polyfill) via the new `temporal` option; asking for useTemporal with no implementation available throws a descriptive error. Default (option-less) behavior is unchanged. Fixes #69 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019m453gv37rxPBWFRDC5MiW --- CHANGELOG.md | 5 ++ README.md | 46 +++++++++++++++++++ index.d.ts | 36 ++++++++++++++- index.js | 4 +- lib/compiler.js | 38 ++++++++++++++-- lib/parser.js | 2 +- package-lock.json | 21 +++++++++ package.json | 1 + src/toml.pegjs | 2 +- test/test_toml.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 260 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acade8..dfceb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +4.2.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.1.2 - June 30 2026 ===================== diff --git a/README.md b/README.md index bb47206..653f457 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,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 7e9052b..62f3190 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,37 @@ declare module 'toml' { - export function parse(input: string): any; + /** + * 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 { + /** + * 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 57f8283..a82916c 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,9 @@ var parser = require('./lib/parser'); var compiler = require('./lib/compiler'); module.exports = { - parse: function(input) { + parse: function(input, options) { var str = input.toString(); var nodes = parser.parse(str); - 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 019d181..147c593 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -344,7 +344,7 @@ function peg$parse(input, options) { function peg$f56(t, frac) { return frac ? t + frac : t } function peg$f57(t) { return t + ':00' } function peg$f58() { return "Z" } - function peg$f59(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$f59(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$f60(d, t) { var off = offset(); validateDate(d, off); validateTime(t, off); return node('LocalDateTime', d + "T" + t, off) } function peg$f61(d) { var off = offset(); validateDate(d, off); return node('LocalDate', d, off) } function peg$f62(t) { var off = offset(); validateTime(t, off); return node('LocalTime', t, off) } diff --git a/package-lock.json b/package-lock.json index bdd932b..f7f2019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,26 @@ "version": "4.1.2", "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 8a5092a..2e7dea4 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 18d325b..0213453 100644 --- a/src/toml.pegjs +++ b/src/toml.pegjs @@ -312,7 +312,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 43a1796..c69f354 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 } });