diff --git a/.github/workflows/node-engine-check.yml b/.github/workflows/node-engine-check.yml index 6a9200fc..d954897e 100644 --- a/.github/workflows/node-engine-check.yml +++ b/.github/workflows/node-engine-check.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "24" + node-version: "26" - name: Setup pnpm uses: pnpm/action-setup@v6 diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml index 8eff2d11..c92340ec 100644 --- a/.github/workflows/test-and-build.yml +++ b/.github/workflows/test-and-build.yml @@ -15,6 +15,7 @@ jobs: include: - node-version: 22.x - node-version: 24.x + - node-version: 26.x benchmark: true steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7606f86e..2009a2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Parsing: New `temporal` option in `ParseOptions`. When `true`, TOML date/time values are returned as [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects (`Temporal.PlainDate`, `Temporal.PlainTime`, `Temporal.PlainDateTime`, `Temporal.ZonedDateTime`) instead of custom `Date` subclasses. +- Stringify: Auto-detect Temporal objects in the input JS and serialize them to the correct TOML date/time type. `ZonedDateTime` IANA annotations are stripped (TOML only supports offsets). + ## [2.0.0] - 2026-05-31 ### Changed diff --git a/PLAN-temporal.md b/PLAN-temporal.md new file mode 100644 index 00000000..cf09ea12 --- /dev/null +++ b/PLAN-temporal.md @@ -0,0 +1,389 @@ +# Plan: Temporal API Support for toml-patch + +## Overview + +The TOML spec has 4 date/time types that map cleanly to the [Temporal API](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) (Stage 4, shipping in browsers and Node.js): + +| TOML Type | Temporal Type | +|---|---| +| Offset Date-Time | `Temporal.ZonedDateTime` | +| Local Date-Time | `Temporal.PlainDateTime` | +| Local Date | `Temporal.PlainDate` | +| Local Time | `Temporal.PlainTime` | + +The implementation touches two independent concerns: + +- **Parsing** (TOML → JS): opt-in via a new `temporal: boolean` option in `ParseOptions` (default `false`). +- **Serializing** (JS → TOML): automatic — if a Temporal object is found in the input JS object, it serializes to the correct TOML date/time type. + +--- + +## Files to Modify + +| # | File | Change | +|---|---|---| +| 1 | `src/parse-options.ts` | Add `temporal?: boolean` | +| 2 | `src/utils.ts` | Add `isTemporal()` guard, update `datesEqual`, `stableStringify` | +| 3 | `src/to-js.ts` | Thread `temporal` option; DateTime → Temporal when enabled | +| 4 | `src/parse-js.ts` | Detect Temporal objects in `walkValue()` | +| 5 | `src/generate.ts` | `generateDateTime` to accept Temporal objects | +| 6 | `src/date-format.ts` | `createDateWithOriginalFormat` to handle Temporal | +| 7 | `src/patch.ts` | `preserveFormatting` for Temporal values | +| 8 | `src/diff.ts` | Ensure date comparison works with Temporal | +| 9 | `src/index.ts` | Export new option type, update JSDoc | +| 10 | `src/__tests__/temporal.test.ts` | New test file | + +--- + +## Step 1 — Add `temporal` option to `ParseOptions` + +**File:** `src/parse-options.ts` + +```ts +export interface ParseOptions { + integersAsBigInt?: IntegersAsBigInt; + /** When true, TOML date/time values are returned as Temporal objects + * instead of custom Date subclasses. Default: false. */ + temporal?: boolean; +} +``` + +**File:** `src/index.ts` — update `parse()` JSDoc to document the new option. + +--- + +## Step 2 — Temporal type guards in `utils.ts` + +**File:** `src/utils.ts` + +Add a duck-type check for Temporal objects: + +```ts +export function isTemporal(value: any): boolean { + return value != null + && typeof value === 'object' + && typeof value.toString === 'function' + && value.constructor?.name?.startsWith?.('Temporal.'); +} +``` + +Update `datesEqual` to handle Temporal: + +```ts +export function datesEqual(a: any, b: any): boolean { + if (isTemporal(a) && isTemporal(b)) { + return a.toString() === b.toString(); + } + return isDate(a) && isDate(b) && a.toISOString() === b.toISOString(); +} +``` + +Update `stableStringify` to handle Temporal: + +```ts +// In stableStringify, after the isObject check: +if (isTemporal(object)) { + return JSON.stringify(object.toString()); +} +``` + +--- + +## Step 3 — Parse path: DateTime CST nodes → Temporal objects + +**File:** `src/to-js.ts` + +### 3a. Thread `temporal` option + +The `toJS()` function signature currently accepts `integersAsBigInt`. Add `temporal`: + +```ts +export default function toJS( + cst: CST, + input: string = '', + integersAsBigInt: IntegersAsBigInt = 'asNeeded', + temporal: boolean = false +): any { +``` + +Also update `toValue()`: + +```ts +export function toValue( + node: Value, + integersAsBigInt: IntegersAsBigInt = 'asNeeded', + temporal: boolean = false +): any { +``` + +### 3b. Convert DateTime to Temporal + +In the `NodeType.DateTime` case of `toValue()`: + +```ts +case NodeType.DateTime: + if (temporal) { + if (typeof Temporal === 'undefined') { + throw new Error( + 'Temporal API is not available in this runtime. ' + + 'Set temporal: false or use a runtime with Temporal support.' + ); + } + return dateValueToTemporal(node.value, node.raw); + } + return node.value; +``` + +### 3c. New helper: `dateValueToTemporal` + +New function (could live in `src/date-format.ts` or a new `src/temporal.ts`): + +```ts +import { LocalDate, LocalTime, LocalDateTime, OffsetDateTime } from './date-format'; + +function dateValueToTemporal(value: Date, raw: string): any { + // LocalDate → Temporal.PlainDate + if (value instanceof LocalDate) { + return Temporal.PlainDate.from(value.toISOString()); + } + // LocalTime → Temporal.PlainTime + if (value instanceof LocalTime) { + return Temporal.PlainTime.from(value.toISOString()); + } + // LocalDateTime → Temporal.PlainDateTime + if (value instanceof LocalDateTime) { + return Temporal.PlainDateTime.from(value.toISOString()); + } + // OffsetDateTime → Temporal.ZonedDateTime + if (value instanceof OffsetDateTime) { + const iso = value.toISOString(); + // Extract offset: e.g. "2024-01-15T10:30:00+05:30" + // ZonedDateTime needs a timezone; we use the offset directly + const offsetMatch = iso.match(/([+-]\d{2}:\d{2}|Z)$/); + const offset = offsetMatch ? offsetMatch[1] : 'Z'; + const plainIso = iso.replace(/([+-]\d{2}:\d{2}|Z)$/, ''); + return Temporal.ZonedDateTime.from(`${plainIso}${offset}[${offset}]`); + } + // Fallback: native Date → PlainDateTime + return Temporal.PlainDateTime.from(value.toISOString().replace('Z', '')); +} +``` + +--- + +## Step 4 — Serialize path: detect Temporal in JS → TOML + +**File:** `src/parse-js.ts` + +In `walkValue()`, before the `isDate` check: + +```ts +import { isTemporal } from './utils'; + +function walkValue(value: any, format: TomlFormat): Value { + // ... existing checks ... + + if (isTemporal(value)) { + return generateTemporalDateTime(value, format.truncateZeroTimeInDates); + } + if (isDate(value)) { + return generateDateTime(value, format.truncateZeroTimeInDates); + } + // ... +} +``` + +**File:** `src/generate.ts` + +New function `generateTemporalDateTime`: + +```ts +export function generateTemporalDateTime( + value: any, + truncateZeroTimeInDates: boolean = false +): DateTime { + const constructorName: string = value.constructor?.name ?? ''; + + let raw: string; + + if (constructorName === 'Temporal.PlainDate') { + raw = value.toString(); + // truncateZeroTimeInDates doesn't apply — PlainDate is always date-only + } else if (constructorName === 'Temporal.PlainTime') { + raw = value.toString(); + } else if (constructorName === 'Temporal.PlainDateTime') { + raw = value.toString(); + } else if (constructorName === 'Temporal.ZonedDateTime') { + // TOML only supports offset, not IANA timezone. + // Use the offset from the ZonedDateTime. + raw = value.toString({ timeZoneName: 'never', offset: 'auto', smallestUnit: 'millisecond' }); + } else { + // Unknown Temporal type — fall back to toString() + raw = value.toString(); + } + + return { + type: NodeType.DateTime, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} +``` + +> **Note for `ZonedDateTime`:** `toString()` returns something like `"2024-01-15T10:30:00+05:30[Asia/Kolkata]"`. The `[Asia/Kolkata]` IANA annotation is not valid TOML. Use `toString({ timeZoneName: 'never' })` to suppress it, giving `"2024-01-15T10:30:00+05:30"` which is valid TOML offset date-time. + +Also update the `toJSON()` helper in `parse-js.ts` — skip Temporal objects (don't call `.toJSON()` on them, they already represent themselves): + +```ts +function toJSON(value: any): any { + if (!value) return value; + if (isDate(value)) return value; + if (isTemporal(value)) return value; // Temporal objects represent themselves + if (typeof value.toJSON === 'function') return value.toJSON(); + return value; +} +``` + +--- + +## Step 5 — Diff/Patch Temporal compatibility + +### 5a. `datesEqual` and `stableStringify` (already covered in Step 2) + +### 5b. `patch.ts` — `preserveFormatting` + +In `preserveFormatting()`, the DateTime branch currently calls `DateFormatHelper.createDateWithOriginalFormat(newValue, originalRaw)` which expects `Date` subclasses. When `newValue` is a Temporal object, it already carries its own type information (a `Temporal.PlainDate` will always serialize as date-only), so we can skip the format-preservation step: + +```ts +if (isDateTime(existing) && isDateTime(replacement)) { + if (isTemporal(replacement.value)) { + // Temporal objects preserve their own type — no format conversion needed. + // Just update the raw string from the Temporal's toString(). + replacement.raw = replacement.value.toString(); + replacement.loc.end.column = replacement.loc.start.column + replacement.raw.length; + } else { + // existing Date subclass logic + const formattedDate = DateFormatHelper.createDateWithOriginalFormat(replacement.value, existing.raw); + replacement.value = formattedDate; + replacement.raw = formattedDate.toISOString(); + replacement.loc.end.column = replacement.loc.start.column + replacement.raw.length; + } +} +``` + +### 5c. `patchCst` — thread `temporal` to `toJS` + +When calling `toJS(items)` and `toJS(updated_document.items)` inside `patchCst`, we need to pass the `temporal` option. The `patch()` function signature may need to accept the option too, or we can detect Temporal in the `updated` object and set it automatically. + +Simpler approach: auto-detect. If the `updated` JS object contains any Temporal values, enable temporal mode for the diff: + +```ts +function hasTemporal(obj: any, seen = new WeakSet()): boolean { + if (obj == null || typeof obj !== 'object') return false; + if (isTemporal(obj)) return true; + if (seen.has(obj)) return false; + seen.add(obj); + for (const v of Object.values(obj)) { + if (hasTemporal(v, seen)) return true; + } + return false; +} +``` + +--- + +## Step 6 — Verify stringify integration + +`stringify()` calls `parseJS()` → `walkValue()` → `generateTemporalDateTime()`. Once Step 4 is done, stringify naturally handles Temporal objects. No extra changes needed. + +--- + +## Step 7 — Export and JSDoc update + +**File:** `src/index.ts` + +- Export `isTemporal` from utils (optional, but useful for consumers) +- Update `parse()` JSDoc to document `temporal` option + +--- + +## Step 8 — Tests + +**File:** `src/__tests__/temporal.test.ts` + +### Test cases + +1. **Parse with `temporal: true`:** + - `"2024-01-15"` → `Temporal.PlainDate` + - `"10:30:00"` → `Temporal.PlainTime` + - `"2024-01-15T10:30:00"` → `Temporal.PlainDateTime` + - `"2024-01-15T10:30:00+05:30"` → `Temporal.ZonedDateTime` + - `"2024-01-15 10:30:00-05:00"` (space separator) → `Temporal.ZonedDateTime` + +2. **Parse with `temporal: false` (default):** + - All date/time values return custom Date subclasses (existing behavior unchanged) + +3. **Parse with `temporal: true` but no Temporal runtime:** + - Skip or mock if Temporal isn't available; test that it throws a clear error + +4. **Stringify with Temporal input:** + ```ts + stringify({ d: Temporal.PlainDate.from("2024-01-15") }) + // → 'd = 2024-01-15\n' + ``` + ```ts + stringify({ t: Temporal.PlainTime.from("10:30:00.123") }) + // → 't = 10:30:00.123\n' + ``` + ```ts + stringify({ dt: Temporal.PlainDateTime.from("2024-01-15T10:30:00") }) + // → 'dt = 2024-01-15T10:30:00\n' + ``` + ```ts + stringify({ z: Temporal.ZonedDateTime.from("2024-01-15T10:30:00+05:30[Asia/Kolkata]") }) + // → 'z = 2024-01-15T10:30:00+05:30\n' + ``` + +5. **Patch with Temporal:** + ```ts + patch('d = 2024-01-15\n', { d: Temporal.PlainDate.from("2025-06-01") }) + // → 'd = 2025-06-01\n' + ``` + +6. **Roundtrip:** + ```ts + const obj = parse(tomlStr, { temporal: true }); + const out = stringify(obj); + const obj2 = parse(out, { temporal: true }); + // obj and obj2 should be equivalent + ``` + +### Note on test environment + +Temporal is available in recent V8/Node.js versions. Tests may need to check `typeof Temporal !== 'undefined'` and skip if unavailable, or use a `describeIf` pattern. + +--- + +## Execution Order + +| Order | Step | Files | Risk | +|---|---|---|---| +| 1 | Add `temporal` option | `parse-options.ts` | Low | +| 2 | Temporal type guards | `utils.ts` | Low | +| 3 | Parse path conversion | `to-js.ts`, new helper | Medium | +| 4 | Serialize path detection | `parse-js.ts`, `generate.ts` | Medium | +| 5 | Diff/patch compatibility | `utils.ts`, `patch.ts`, `date-format.ts` | Medium | +| 6 | Verify stringify | just verify | Low | +| 7 | Export & JSDoc | `index.ts` | Low | +| 8 | Tests | new `__tests__/temporal.test.ts` | Low | + +--- + +## Design Decisions + +1. **`temporal` is opt-in (default `false`).** This avoids a breaking change. In a future major version it could become opt-out or the default once Temporal reaches Baseline. +2. **Temporal detection in serialize path is automatic.** If a user has a Temporal object in their JS, we want it to serialize correctly without extra configuration. +3. **Duck-typing for Temporal detection** rather than `instanceof` checks, because Temporal objects may come from different realms (e.g., iframes, vm contexts). +4. **For `ZonedDateTime` with IANA timezone**, we strip the IANA annotation and keep only the offset in TOML output. TOML does not support IANA timezone names. +5. **`patch()` auto-detects Temporal** in the `updated` object to enable temporal mode for the internal `toJS` diffs, avoiding a change to the `patch()` signature. diff --git a/README.md b/README.md index f3dbb697..d4b21e93 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ We hope that these improvements can be incorporated upstream one day if the orig - [Methods](#methods) - [patch() Example](#patch-example) - [update() Example](#update-example) +- [Date/Time Handling & Temporal](#datetime-handling--temporal) - [Formatting](#formatting) - [TomlFormat Class](#tomlformat-class) - [Basic Usage](#basic-usage) @@ -162,6 +163,7 @@ Parses a TOML string (or raw UTF-8 bytes) into a JavaScript object. - `'asNeeded'` *(default)* — integers within the JS safe-integer range are `number`; larger values are `bigint` to preserve precision - `true` — all integers are returned as `bigint` - `false` — all integers are returned as `number` (large values lose precision) + - `temporal?: boolean` — When `true`, TOML date/time values are returned as [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects instead of custom `Date` subclasses. Default: `false`. See [Date/Time Handling](#datetime-handling--temporal). **Returns:** `any` - The parsed JavaScript object @@ -250,6 +252,7 @@ Initializes the TomlDocument with TOML source, parsing it into an internal repre - `'asNeeded'` *(default)* — integers within the JS safe-integer range are `number`; larger values are `bigint` to preserve precision - `true` — all integers are returned as `bigint` - `false` — all integers are returned as `number` (large values lose precision) + - `temporal?: boolean` — When `true`, TOML date/time values are returned as [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects. Default: `false`. See [Date/Time Handling](#datetime-handling--temporal). ##### Basic Usage Example @@ -375,6 +378,12 @@ doc.update(updatedToml); console.log(doc.toJsObject.server.port); // 3000 ``` +## Date/Time Handling & Temporal + +TOML date/time values are parsed into custom `Date` subclasses (`LocalDate`, `LocalTime`, `LocalDateTime`, `OffsetDateTime`) by default. Set `temporal: true` to receive [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects instead. `stringify()` and `patch()` auto-detect Temporal objects and serialize them correctly. + +See **[docs/Dates.md](docs/Dates.md)** for details and examples. + ## Formatting The `TomlFormat` class provides decent control over how TOML documents are formatted during stringification and patching operations. This class encapsulates all formatting preferences, making it easy to maintain consistent styling across your TOML documents. diff --git a/docs/Dates.md b/docs/Dates.md new file mode 100644 index 00000000..3c683fee --- /dev/null +++ b/docs/Dates.md @@ -0,0 +1,92 @@ +# Date/Time Handling & Temporal + +TOML supports four date/time types: [offset date-time](https://toml.io/en/v1.1.0#offset-date-time), [local date-time](https://toml.io/en/v1.1.0#local-date-time), [local date](https://toml.io/en/v1.1.0#local-date), and [local time](https://toml.io/en/v1.1.0#local-time). This library preserves each type's semantics through both parsing and serialization. + +## Default behavior (Date subclasses) + +By default (`temporal: false`), TOML date/time values are parsed into custom `Date` subclasses that preserve the original TOML format: + +| TOML example | JS class | +|---|---| +| `2024-01-15` | `LocalDate` | +| `10:30:00` | `LocalTime` | +| `2024-01-15T10:30:00` | `LocalDateTime` | +| `2024-01-15T10:30:00+05:30` | `OffsetDateTime` | + +Each class extends `Date`, so you can treat them as normal `Date` objects. When stringified back to TOML, each class serializes to the correct format automatically — a `LocalDate` never gains a time component, and an `OffsetDateTime` preserves its timezone offset. + +## Temporal API (opt-in) + +Set `temporal: true` in `ParseOptions` to receive [Temporal](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Temporal) objects instead: + +| TOML type | Temporal type | +|---|---| +| Offset Date-Time | `Temporal.ZonedDateTime` | +| Local Date-Time | `Temporal.PlainDateTime` | +| Local Date | `Temporal.PlainDate` | +| Local Time | `Temporal.PlainTime` | + +```js +import { parse } from '@decimalturn/toml-patch'; + +const obj = parse( + 'd = 2024-01-15\nz = 2024-01-15T10:30:00+05:30\n', + { temporal: true } +); +// obj.d → Temporal.PlainDate +// obj.z → Temporal.ZonedDateTime +``` + +### Runtime requirements + +Temporal is Stage 4 and available in modern browsers. For runtimes without native support (including Node.js 24 and earlier), use [`@js-temporal/polyfill`](https://www.npmjs.com/package/@js-temporal/polyfill) and set it on `globalThis` before parsing: + +```js +import { Temporal } from '@js-temporal/polyfill'; +globalThis.Temporal = Temporal; + +// Now parse() with temporal: true works +const obj = parse('d = 2024-01-15\n', { temporal: true }); +``` + +## Temporal in stringify and patch + +`stringify()` and `patch()` auto-detect Temporal objects in the input JS — no option needed: + +```js +import { stringify, patch } from '@decimalturn/toml-patch'; + +stringify({ + start: Temporal.PlainDate.from('2024-01-15'), + due: Temporal.ZonedDateTime.from('2024-12-31T23:59:59Z[UTC]') +}); +// start = 2024-01-15 +// due = 2024-12-31T23:59:59Z + +patch('d = 2024-01-15\n', { + d: Temporal.PlainDateTime.from('2025-06-01T12:00:00') +}); +// d = 2025-06-01T12:00:00 +``` + +> **Note:** TOML only supports timezone offsets (`+05:30`, `Z`), not IANA timezone names. When a `Temporal.ZonedDateTime` with an IANA annotation (e.g. `[Asia/Kolkata]`) is serialized, only the offset portion is kept. + +## Format transitions + +When patching, the output format automatically adapts to the new Temporal type. Upgrading a date-only value to a `PlainDateTime` adds the time component; downgrading a `ZonedDateTime` to a `PlainDate` strips the time and offset: + +```js +// Upgrade: date-only → datetime +patch('d = 2024-01-15\n', { d: Temporal.PlainDateTime.from('2025-06-01T12:00:00') }); +// → 'd = 2025-06-01T12:00:00' + +// Downgrade: offset datetime → date-only +patch('z = 2024-01-15T10:30:00+05:30\n', { z: Temporal.PlainDate.from('2025-06-01') }); +// → 'z = 2025-06-01' +``` + +## Integer representation + +Date/time handling is often paired with the `integersAsBigInt` option which controls how TOML integers are represented in JS. + +See the [main README](../README.md#parse) for details on `ParseOptions`. diff --git a/package.json b/package.json index 36bddb41..8f321670 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ }, "devDependencies": { "@decimalturn/toml-patch": "npm:@decimalturn/toml-patch@2.0.0", + "@js-temporal/polyfill": "^0.5.1", "@playwright/test": "^1.59.1", "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.2", @@ -78,8 +79,8 @@ "dedent": "^1.5.3", "glob": "^13.0.0", "jest": "^30.4.1", - "jsr": "^0.14.0", "js-yaml": "^4.0.0", + "jsr": "^0.14.0", "mri": "^1", "npm-run-all2": "^9.0.0", "oxlint": "^1.55.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc378b73..fa094d3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@decimalturn/toml-patch': specifier: npm:@decimalturn/toml-patch@2.0.0 version: 2.0.0 + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@playwright/test': specifier: ^1.59.1 version: 1.61.0 @@ -496,6 +499,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1734,6 +1741,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3222,6 +3232,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -4572,6 +4586,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} diff --git a/renovate.jsonc b/renovate.jsonc index b82dbadb..5d628b9c 100644 --- a/renovate.jsonc +++ b/renovate.jsonc @@ -23,7 +23,10 @@ // A negation-only pattern matches everything except what's negated. // Equivalent to ["*", "!pnpm"] but avoids Renovate's validation error // when mixing * or ** with other patterns. - "matchUpdateTypes": ["minor", "patch"] + "matchUpdateTypes": ["minor", "patch"], + // Force group-style commit message even when only one dep is updated, + // avoiding the single-dep template that appends "to vX.Y.Z". + "commitMessageTopic": "all non-major devDependencies" } ] } diff --git a/src/__tests__/temporal.test.ts b/src/__tests__/temporal.test.ts new file mode 100644 index 00000000..e86bb2a5 --- /dev/null +++ b/src/__tests__/temporal.test.ts @@ -0,0 +1,433 @@ +/** + * Tests for Temporal API support. + * + * Uses @js-temporal/polyfill because Node.js 24 does not yet ship + * Temporal natively (Stage 4 spec, expected in a future release). + * The polyfill is set on globalThis so the library code can access it + * the same way it would access the native Temporal global. + */ +import { Temporal } from '@js-temporal/polyfill'; + +// Make Temporal available as a global so the library code can find it +// via globalThis.Temporal (same as native Temporal in browsers/Node.js). +(globalThis as any).Temporal = Temporal; + +import { parse, stringify, patch } from '../index'; +import { kitchen_sink, example } from '../__fixtures__'; + +const FMT = { trailingNewline: 0 }; + +// --------------------------------------------------------------------------- +// Parse: temporal option +// --------------------------------------------------------------------------- + +describe('parse() with temporal: true', () => { + + it('parses local date → Temporal.PlainDate', () => { + const obj = parse('d = 2024-01-15\n', { temporal: true }); + expect(obj.d).toBeInstanceOf(Temporal.PlainDate); + expect(obj.d.toString()).toBe('2024-01-15'); + }); + + it('parses local time → Temporal.PlainTime', () => { + const obj = parse('t = 10:30:00\n', { temporal: true }); + expect(obj.t).toBeInstanceOf(Temporal.PlainTime); + expect(obj.t.toString()).toBe('10:30:00'); + }); + + it('parses local time with milliseconds → Temporal.PlainTime', () => { + const obj = parse('t = 10:30:00.123\n', { temporal: true }); + expect(obj.t).toBeInstanceOf(Temporal.PlainTime); + expect(obj.t.toString()).toBe('10:30:00.123'); + }); + + it('parses local datetime → Temporal.PlainDateTime', () => { + const obj = parse('dt = 2024-01-15T10:30:00\n', { temporal: true }); + expect(obj.dt).toBeInstanceOf(Temporal.PlainDateTime); + expect(obj.dt.toString()).toBe('2024-01-15T10:30:00'); + }); + + it('parses local datetime with space separator → Temporal.PlainDateTime', () => { + const obj = parse('dt = 2024-01-15 10:30:00\n', { temporal: true }); + expect(obj.dt).toBeInstanceOf(Temporal.PlainDateTime); + }); + + it('parses offset datetime → Temporal.ZonedDateTime (Z offset)', () => { + const obj = parse('z = 2024-01-15T10:30:00Z\n', { temporal: true }); + expect(obj.z).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + it('parses offset datetime → Temporal.ZonedDateTime (+05:30 offset)', () => { + const obj = parse('z = 2024-01-15T10:30:00+05:30\n', { temporal: true }); + expect(obj.z).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + it('parses offset datetime with space separator', () => { + const obj = parse('z = 2024-01-15 10:30:00-05:00\n', { temporal: true }); + expect(obj.z).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + it('roundtrips space-separated offset datetime', () => { + const obj = parse('z = 2024-01-15 10:30:00-05:00\n', { temporal: true }); + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.z).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + it('default (temporal: false) still returns Date subclasses', () => { + const obj = parse('d = 2024-01-15\n'); + expect(obj.d).toBeInstanceOf(Date); + // Date subclass, not plain Date, so constructor name is not 'Date' + expect(obj.d.constructor.name).toBe('LocalDate'); + }); + + it('temporal: false explicitly returns Date subclasses', () => { + const obj = parse('d = 2024-01-15\n', { temporal: false }); + expect(obj.d.constructor.name).toBe('LocalDate'); + }); +}); + +// --------------------------------------------------------------------------- +// Stringify: Temporal input +// --------------------------------------------------------------------------- + +describe('stringify() with Temporal input', () => { + + it('serializes Temporal.PlainDate → TOML date-only', () => { + const d = Temporal.PlainDate.from('2024-01-15'); + const out = stringify({ date: d }, FMT); + expect(out).toBe('date = 2024-01-15'); + }); + + it('serializes Temporal.PlainTime → TOML time-only', () => { + const t = Temporal.PlainTime.from('10:30:00'); + const out = stringify({ time: t }, FMT); + expect(out).toBe('time = 10:30:00'); + }); + + it('serializes Temporal.PlainTime with milliseconds', () => { + const t = Temporal.PlainTime.from('10:30:00.123'); + const out = stringify({ time: t }, FMT); + expect(out).toBe('time = 10:30:00.123'); + }); + + it('serializes Temporal.PlainDateTime → TOML local datetime', () => { + const dt = Temporal.PlainDateTime.from('2024-01-15T10:30:00'); + const out = stringify({ dt }, FMT); + expect(out).toBe('dt = 2024-01-15T10:30:00'); + }); + + it('serializes Temporal.ZonedDateTime → TOML offset datetime', () => { + const z = Temporal.ZonedDateTime.from('2024-01-15T10:30:00+05:30[+05:30]'); + const out = stringify({ z }, FMT); + expect(out).toBe('z = 2024-01-15T10:30:00+05:30'); + }); + + it('serializes Temporal.ZonedDateTime with Z offset', () => { + const z = Temporal.ZonedDateTime.from('2024-01-15T10:30:00+00:00[+00:00]'); + const out = stringify({ z }, FMT); + expect(out).toBe('z = 2024-01-15T10:30:00Z'); + }); +}); + +// --------------------------------------------------------------------------- +// Roundtrip: parse → stringify → parse +// --------------------------------------------------------------------------- + +describe('roundtrip with Temporal', () => { + + it('PlainDate roundtrips correctly', () => { + const toml = 'd = 2024-01-15\n'; + const obj = parse(toml, { temporal: true }); + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.d).toBeInstanceOf(Temporal.PlainDate); + expect(obj2.d.toString()).toBe('2024-01-15'); + }); + + it('PlainTime roundtrips correctly', () => { + const toml = 't = 10:30:00.123\n'; + const obj = parse(toml, { temporal: true }); + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.t).toBeInstanceOf(Temporal.PlainTime); + expect(obj2.t.toString()).toBe('10:30:00.123'); + }); + + it('PlainDateTime roundtrips correctly', () => { + const toml = 'dt = 2024-01-15T10:30:00\n'; + const obj = parse(toml, { temporal: true }); + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.dt).toBeInstanceOf(Temporal.PlainDateTime); + expect(obj2.dt.toString()).toBe('2024-01-15T10:30:00'); + }); + + it('ZonedDateTime roundtrips correctly', () => { + const toml = 'z = 2024-01-15T10:30:00+05:30\n'; + const obj = parse(toml, { temporal: true }); + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.z).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + // -- format transitions verified by re-parsing after patch -- + + it('date-only → PlainDateTime survives roundtrip', () => { + const toml = 'd = 2024-01-15\n'; + const updated = { d: Temporal.PlainDateTime.from('2025-06-01T12:00:00') }; + const patched = patch(toml, updated, FMT); + const reparsed = parse(patched, { temporal: true }); + expect(reparsed.d).toBeInstanceOf(Temporal.PlainDateTime); + expect(reparsed.d.toString()).toBe('2025-06-01T12:00:00'); + }); + + it('date-only → ZonedDateTime survives roundtrip', () => { + const toml = 'd = 2024-01-15\n'; + const updated = { d: Temporal.ZonedDateTime.from('2025-06-01T12:00:00+05:30[+05:30]') }; + const patched = patch(toml, updated, FMT); + const reparsed = parse(patched, { temporal: true }); + expect(reparsed.d).toBeInstanceOf(Temporal.ZonedDateTime); + }); + + it('datetime → PlainDate survives roundtrip', () => { + const toml = 'dt = 2024-01-15T10:30:00\n'; + const updated = { dt: Temporal.PlainDate.from('2025-06-01') }; + const patched = patch(toml, updated, FMT); + const reparsed = parse(patched, { temporal: true }); + expect(reparsed.dt).toBeInstanceOf(Temporal.PlainDate); + expect(reparsed.dt.toString()).toBe('2025-06-01'); + }); + + it('offset datetime → PlainDate survives roundtrip', () => { + const toml = 'z = 2024-01-15T10:30:00+05:30\n'; + const updated = { z: Temporal.PlainDate.from('2025-06-01') }; + const patched = patch(toml, updated, FMT); + const reparsed = parse(patched, { temporal: true }); + expect(reparsed.z).toBeInstanceOf(Temporal.PlainDate); + expect(reparsed.z.toString()).toBe('2025-06-01'); + }); +}); + +// --------------------------------------------------------------------------- +// Patch with Temporal +// --------------------------------------------------------------------------- + +describe('patch() with Temporal', () => { + + it('patches a date value with Temporal.PlainDate', () => { + const existing = 'd = 2024-01-15\n'; + const updated = { d: Temporal.PlainDate.from('2025-06-01') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('d = 2025-06-01'); + }); + + it('patches a datetime value with Temporal.PlainDateTime', () => { + const existing = 'dt = 2024-01-15T10:30:00\n'; + const updated = { dt: Temporal.PlainDateTime.from('2025-06-01T12:00:00') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('dt = 2025-06-01T12:00:00'); + }); + + it('patches a ZonedDateTime value preserving offset format', () => { + const existing = 'z = 2024-01-15T10:30:00+05:30\n'; + const updated = { z: Temporal.ZonedDateTime.from('2025-06-01T12:00:00+05:30[+05:30]') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('z = 2025-06-01T12:00:00+05:30'); + }); + + it('patch with mix of Temporal and plain values works', () => { + const existing = 'name = "test"\nd = 2024-01-15\ncount = 42\n'; + const updated = { + name: 'test', + d: Temporal.PlainDate.from('2025-06-01'), + count: 43 + }; + const result = patch(existing, updated, FMT); + expect(result).toBe('name = "test"\nd = 2025-06-01\ncount = 43'); + }); + + // -- format transitions (upgrade / downgrade) -- + + it('upgrades date-only → PlainDateTime when new value carries time', () => { + const existing = 'd = 2024-01-15\n'; + const updated = { d: Temporal.PlainDateTime.from('2025-06-01T12:00:00') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('d = 2025-06-01T12:00:00'); + }); + + it('upgrades date-only → ZonedDateTime when new value carries offset', () => { + const existing = 'd = 2024-01-15\n'; + const updated = { d: Temporal.ZonedDateTime.from('2025-06-01T12:00:00+05:30[+05:30]') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('d = 2025-06-01T12:00:00+05:30'); + }); + + it('downgrades datetime → date-only when new value is PlainDate', () => { + const existing = 'dt = 2024-01-15T10:30:00\n'; + const updated = { dt: Temporal.PlainDate.from('2025-06-01') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('dt = 2025-06-01'); + }); + + it('downgrades offset datetime → date-only when new value is PlainDate', () => { + const existing = 'z = 2024-01-15T10:30:00+05:30\n'; + const updated = { z: Temporal.PlainDate.from('2025-06-01') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('z = 2025-06-01'); + }); + + it('preserves space separator when patching with Temporal', () => { + const existing = 'z = 2024-01-15 10:30:00+05:30\n'; + const updated = { z: Temporal.ZonedDateTime.from('2025-06-01T12:00:00+05:30[+05:30]') }; + const result = patch(existing, updated, FMT); + expect(result).toBe('z = 2025-06-01 12:00:00+05:30'); + }); + + // -- ZonedDateTime with different IANA zones but same offset should not diff -- + + it('ZonedDateTime with IANA annotation throws a clear error', () => { + const existing = 'z = 2024-01-15T10:30:00+05:30\n'; + const updated = { + z: Temporal.ZonedDateTime.from('2024-01-15T10:30:00+05:30[Asia/Kolkata]') + }; + expect(() => patch(existing, updated, FMT)).toThrow( + 'cannot be represented in TOML' + ); + }); +}); + +// --------------------------------------------------------------------------- +// Fixture-based roundtrip: existing TOML fixtures with date/time values +// --------------------------------------------------------------------------- + +describe('fixture roundtrip with temporal: true', () => { + + it('kitchen-sink: all date/time types survive roundtrip', () => { + const obj = parse(kitchen_sink, { temporal: true }); + + // --- First pass: verify parsed types --- + + const dtArray = obj.values.date.datetime; + expect(Array.isArray(dtArray)).toBe(true); + + // 1979-05-27T07:32:00Z → ZonedDateTime + expect(dtArray[0]).toBeInstanceOf(Temporal.ZonedDateTime); + + // 1979-05-27T00:32:00-07:00 → ZonedDateTime + expect(dtArray[1]).toBeInstanceOf(Temporal.ZonedDateTime); + + // 1979-05-27T00:32:00.999999-07:00 → ZonedDateTime + expect(dtArray[2]).toBeInstanceOf(Temporal.ZonedDateTime); + + // 1979-05-27 07:32:00Z (space separator) → ZonedDateTime + expect(dtArray[3]).toBeInstanceOf(Temporal.ZonedDateTime); + + const localArray = obj.values.date.local; + expect(Array.isArray(localArray)).toBe(true); + + // 1979-05-27T07:32:00 → PlainDateTime + expect(localArray[0]).toBeInstanceOf(Temporal.PlainDateTime); + expect(localArray[0].toString()).toBe('1979-05-27T07:32:00'); + + // 1979-05-27 → PlainDate + expect(localArray[1]).toBeInstanceOf(Temporal.PlainDate); + expect(localArray[1].toString()).toBe('1979-05-27'); + + // 07:32:00 → PlainTime + expect(localArray[2]).toBeInstanceOf(Temporal.PlainTime); + expect(localArray[2].toString()).toBe('07:32:00'); + + // --- Roundtrip: stringify → re-parse --- + + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + + const dtArray2 = obj2.values.date.datetime; + expect(dtArray2[0]).toBeInstanceOf(Temporal.ZonedDateTime); + expect(dtArray2[1]).toBeInstanceOf(Temporal.ZonedDateTime); + expect(dtArray2[2]).toBeInstanceOf(Temporal.ZonedDateTime); + expect(dtArray2[3]).toBeInstanceOf(Temporal.ZonedDateTime); + + const localArray2 = obj2.values.date.local; + expect(localArray2[0]).toBeInstanceOf(Temporal.PlainDateTime); + expect(localArray2[0].toString()).toBe('1979-05-27T07:32:00'); + expect(localArray2[1]).toBeInstanceOf(Temporal.PlainDate); + expect(localArray2[1].toString()).toBe('1979-05-27'); + expect(localArray2[2]).toBeInstanceOf(Temporal.PlainTime); + expect(localArray2[2].toString()).toBe('07:32:00'); + }); + + it('example.toml: offset datetime survives roundtrip', () => { + const obj = parse(example, { temporal: true }); + expect(obj.owner.dob).toBeInstanceOf(Temporal.ZonedDateTime); + + const out = stringify(obj, FMT); + const obj2 = parse(out, { temporal: true }); + expect(obj2.owner.dob).toBeInstanceOf(Temporal.ZonedDateTime); + }); +}); + +// --------------------------------------------------------------------------- +// Error cases +// --------------------------------------------------------------------------- + +describe('Temporal error handling', () => { + + it('throws a clear error when temporal: true but Temporal is not available', () => { + const saved = (globalThis as any).Temporal; + try { + delete (globalThis as any).Temporal; + expect(() => parse('d = 2024-01-15\n', { temporal: true })).toThrow( + 'Temporal API is not available' + ); + } finally { + (globalThis as any).Temporal = saved; + } + }); + + it('rejects non-ISO calendar annotations on Temporal values', () => { + const d = Temporal.PlainDate.from('2024-01-15[u-ca=persian]'); + expect(() => stringify({ d }, FMT)).toThrow( + 'unsupported annotation' + ); + }); +}); + +// --------------------------------------------------------------------------- +// Utility function tests +// --------------------------------------------------------------------------- + +describe('isTemporal utility', () => { + // We import via the barrel — test the duck-type detection indirectly + // by verifying that parse with temporal:true returns Temporal instances. + + // Known Temporal constructor names (both native and polyfill variants) + const TEMPORAL_NAMES = new Set([ + 'Temporal.PlainDate', 'PlainDate', + 'Temporal.PlainTime', 'PlainTime', + 'Temporal.PlainDateTime', 'PlainDateTime', + 'Temporal.ZonedDateTime', 'ZonedDateTime' + ]); + + it('Temporal.PlainDate is detected as Temporal (via parse)', () => { + const obj = parse('d = 2024-01-15\n', { temporal: true }); + const d = obj.d; + expect(TEMPORAL_NAMES.has(d.constructor.name)).toBe(true); + }); + + it('Temporal.PlainTime is detected as Temporal (via parse)', () => { + const obj = parse('t = 10:30:00\n', { temporal: true }); + expect(TEMPORAL_NAMES.has(obj.t.constructor.name)).toBe(true); + }); + + it('Temporal.PlainDateTime is detected as Temporal (via parse)', () => { + const obj = parse('dt = 2024-01-15T10:30:00\n', { temporal: true }); + expect(TEMPORAL_NAMES.has(obj.dt.constructor.name)).toBe(true); + }); + + it('Temporal.ZonedDateTime is detected as Temporal (via parse)', () => { + const obj = parse('z = 2024-01-15T10:30:00Z\n', { temporal: true }); + expect(TEMPORAL_NAMES.has(obj.z.constructor.name)).toBe(true); + }); +}); diff --git a/src/generate.ts b/src/generate.ts index e2d8d90d..afc71bfe 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -29,7 +29,7 @@ import { shiftNode } from './writer'; import { rebuildLineContinuation } from './line-ending-backslash'; import { IS_BARE_KEY } from './tokenizer'; import { escapeStringContent } from './escape-preference'; -import {isBasicString, isMultilineBasicString, isLiteralString, isMultilineLiteralString} from './utils'; +import {isBasicString, isMultilineBasicString, isLiteralString, isMultilineLiteralString, temporalToTomlString} from './utils'; /** * Generates a new TOML document node. @@ -404,6 +404,76 @@ export function generateDateTime(value: Date, truncateZeroTimeInDates: boolean = }; } +/** + * Generates a DateTime CST node from a Temporal API object. + * + * Each Temporal type serializes to the corresponding TOML date/time format: + * - Temporal.PlainDate → "2024-01-15" + * - Temporal.PlainTime → "10:30:00.123" + * - Temporal.PlainDateTime → "2024-01-15T10:30:00.123" + * - Temporal.ZonedDateTime → "2024-01-15T10:30:00.123+05:30" (no IANA annotation) + * + * @param value - A Temporal object (PlainDate, PlainTime, PlainDateTime, or ZonedDateTime). + * @param truncateZeroTimeInDates - If true, a PlainDateTime with all-zero time is + * downgraded to a PlainDate (date-only output). Has no effect on other Temporal types. + */ +export function generateTemporalDateTime( + value: any, + truncateZeroTimeInDates: boolean = false +): DateTime { + const constructorName: string = value.constructor?.name ?? ''; + + // Detect the Temporal type from the constructor name. + // Supports both native ("Temporal.PlainDate") and polyfill ("PlainDate") naming. + const isPlainDate = constructorName === 'Temporal.PlainDate' || constructorName === 'PlainDate'; + const isPlainTime = constructorName === 'Temporal.PlainTime' || constructorName === 'PlainTime'; + const isPlainDateTime = constructorName === 'Temporal.PlainDateTime' || constructorName === 'PlainDateTime'; + const isZonedDateTime = constructorName === 'Temporal.ZonedDateTime' || constructorName === 'ZonedDateTime'; + + let raw: string; + + if (isPlainDate) { + raw = temporalToTomlString(value); + } else if (isPlainTime) { + raw = temporalToTomlString(value); + } else if (isPlainDateTime) { + // Optionally truncate zero time components to date-only + if (truncateZeroTimeInDates) { + const T = (globalThis as any).Temporal; + if (!T) { + throw new Error( + 'Temporal API is not available in this runtime. ' + + 'Set temporal: false or use a runtime with Temporal support.' + ); + } + const plainDate = T.PlainDate.from(value.toString().split('T')[0]); + if (plainDate.toString() + 'T00:00:00' === value.toString().slice(0, 19)) { + raw = temporalToTomlString(plainDate); + return { + type: NodeType.DateTime, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value: plainDate + }; + } + } + raw = temporalToTomlString(value); + } else if (isZonedDateTime) { + // TOML only supports offset, not IANA timezone names. + raw = temporalToTomlString(value); + } else { + // Unknown Temporal type — fall back to temporalToTomlString() + raw = temporalToTomlString(value); + } + + return { + type: NodeType.DateTime, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} + export function generateInlineArray(): InlineArray { return { type: NodeType.InlineArray, diff --git a/src/index.ts b/src/index.ts index 9ada85f2..60d5494e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,10 +29,18 @@ export type { IntegersAsBigInt, ParseOptions } from './parse-options'; * your code serializes the result to JSON or performs arithmetic mixing `number` * and `bigint`, set `integersAsBigInt: false` to restore the previous behavior. * + * By default (`options.temporal` unset or `false`), TOML date/time values are + * returned as custom Date subclasses (LocalDate, LocalTime, LocalDateTime, + * OffsetDateTime). Set `options.temporal` to `true` to return Temporal API + * objects instead (Temporal.PlainDate, Temporal.PlainTime, Temporal.PlainDateTime, + * Temporal.ZonedDateTime). The Temporal API must be available in the runtime. + * * @param value - TOML source as a string or raw UTF-8 bytes * @param options - Optional parse options * @param options.integersAsBigInt - Controls `bigint` vs `number` for integers. * `'asNeeded'` (default) | `true` | `false` + * @param options.temporal - When true, returns Temporal objects for date/time values. + * Default: false. * @returns The parsed JavaScript object */ export function parse(value: string | Uint8Array, options?: ParseOptions): any { @@ -40,7 +48,10 @@ export function parse(value: string | Uint8Array, options?: ParseOptions): any { ? value : decodeUtf8Bytes(value); const tomlString = stripLeadingBom(rawString); - return toJS(parseTOML(tomlString), tomlString, options?.integersAsBigInt ?? 'asNeeded'); + return toJS(parseTOML(tomlString), tomlString, { + integersAsBigInt: options?.integersAsBigInt ?? 'asNeeded', + temporal: options?.temporal ?? false + }); } /** diff --git a/src/parse-js.ts b/src/parse-js.ts index ae154b5e..8feb18ff 100644 --- a/src/parse-js.ts +++ b/src/parse-js.ts @@ -8,12 +8,13 @@ import { generateFloat, generateBoolean, generateDateTime, + generateTemporalDateTime, generateInlineArray, generateInlineTable } from './generate'; import { TomlFormat } from './toml-format'; import { formatTopLevel, formatEmptyLines, formatNestedTablesMultiline } from './formatter'; -import { isObject, isString, isBigInt, isInteger, isFloat, isBoolean, isDate } from './utils'; +import { isObject, isString, isBigInt, isInteger, isFloat, isBoolean, isDate, isTemporal } from './utils'; import { insert, applyWrites, applyBracketSpacing, applyTrailingComma } from './writer'; /** @@ -71,6 +72,8 @@ function walkValue(value: any, format: TomlFormat): Value { return generateFloat(value, Math.max(minimumDecimals, 1)); } else if (isBoolean(value)) { return generateBoolean(value); + } else if (isTemporal(value)) { + return generateTemporalDateTime(value, format.truncateZeroTimeInDates); } else if (isDate(value)) { return generateDateTime(value, format.truncateZeroTimeInDates); } else if (Array.isArray(value)) { @@ -128,6 +131,11 @@ function toJSON(value: any): any { if (isDate(value)) { return value; } + + // Skip Temporal objects (they represent themselves, don't call toJSON()) + if (isTemporal(value)) { + return value; + } // Use object's custom toJSON method if available if (typeof value.toJSON === 'function') { diff --git a/src/parse-options.ts b/src/parse-options.ts index 4bfe7152..f3a79f9b 100644 --- a/src/parse-options.ts +++ b/src/parse-options.ts @@ -1,5 +1,22 @@ export type IntegersAsBigInt = boolean | 'asNeeded'; export interface ParseOptions { + /** + * Controls how TOML integers are returned in the parsed JavaScript object. + * - `true`: All integers are returned as `bigint`. + * - `false`: All integers are returned as `number` (may lose precision for large integers). + * - `'asNeeded'` (default): Integers that fit within the JavaScript safe-integer range are returned as `number`; larger integers are returned as `bigint`. + */ integersAsBigInt?: IntegersAsBigInt; + /** + * When true, TOML date/time values are parsed into Temporal objects + * (Temporal.PlainDate, Temporal.PlainTime, Temporal.PlainDateTime, + * Temporal.ZonedDateTime) instead of custom Date subclasses. + * + * The Temporal API must be available in the runtime (it is Stage 4 + * and shipping in modern browsers and Node.js). + * + * Default: false (returns custom Date subclasses for backward compatibility). + */ + temporal?: boolean; } diff --git a/src/patch.ts b/src/patch.ts index 8814d7b6..83a61407 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -28,7 +28,7 @@ import { } from './cst'; import diff, { Change, isAdd, isEdit, isRemove, isMove, isRename } from './diff'; import findByPath, { tryFindByPath, findParent } from './find-by-path'; -import { last, isInteger } from './utils'; +import { last, isInteger, isTemporal, temporalToTomlString } from './utils'; import { insert, replace, remove, applyWrites } from './writer'; import { generateInlineItem, generateTable, generateTableArray, generateString } from './generate'; import { IS_BARE_KEY } from './tokenizer'; @@ -70,9 +70,28 @@ export default function patch(existing: string, updated: any, format?: Partial = new WeakSet()): boolean { + if (obj == null || typeof obj !== 'object') return false; + if (isTemporal(obj)) return true; + if (seen.has(obj)) return false; + seen.add(obj); + for (const v of Object.values(obj)) { + if (hasTemporal(v, seen)) return true; + } + return false; +} + export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { tomlString: string; document: Document } { const items = [...existing_cst]; + // Auto-detect Temporal in the updated JS object so that the internal + // toJS() diff uses Temporal objects when the user provides them. + const useTemporal = hasTemporal(updated); + // Compute the Document's end position from its children so that // offset-based position updates in applyWrites start from the correct // baseline (instead of 0,0 which under-counts after expansion). @@ -86,7 +105,7 @@ export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { } } - const existing_js = toJS(items); + const existing_js = toJS(items, '', { temporal: useTemporal }); const existing_document: Document = { type: NodeType.Document, loc: { start: { line: 1, column: 0 }, end: { line: endLine, column: endColumn } }, @@ -103,7 +122,7 @@ export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { // Diff against the JS representation rather than // the raw `updated` value, so that any undefined keys (which parseJS already // stripped) are consistently absent from both sides of the diff. - const updated_js = toJS(updated_document.items); + const updated_js = toJS(updated_document.items, '', { temporal: useTemporal }); const changes = reorder(diff(existing_js, updated_js)); if (changes.length === 0) { @@ -113,7 +132,7 @@ export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { }; } - const patched_document = applyChanges(existing_document, updated_document, changes, format); + const patched_document = applyChanges(existing_document, updated_document, changes, format, useTemporal); const tomlString = normalizeInlineCommentAlignmentInString( patched_document, toTOML(patched_document.items, format), @@ -181,14 +200,26 @@ function preserveFormatting(existing: Value, replacement: Value): void { // Analyze the original raw format and create a properly formatted replacement const originalRaw = existing.raw; const newValue = replacement.value; - - // Create a new date with the original format preserved - const formattedDate = DateFormatHelper.createDateWithOriginalFormat(newValue, originalRaw); - - // Update the replacement with the properly formatted date - replacement.value = formattedDate; - replacement.raw = formattedDate.toISOString(); - replacement.loc.end.column = replacement.loc.start.column + replacement.raw.length; + + if (isTemporal(newValue)) { + // Temporal objects preserve their own type — no format conversion needed. + let raw = temporalToTomlString(newValue); + // Preserve the original separator style (T vs space) from the existing TOML. + if (originalRaw.includes(' ') && raw.includes('T')) { + raw = raw.replace('T', ' '); + } + replacement.raw = raw; + replacement.loc.end.column = replacement.loc.start.column + replacement.raw.length; + // Keep the Temporal object as the value — it will serialize correctly. + } else { + // Create a new date with the original format preserved + const formattedDate = DateFormatHelper.createDateWithOriginalFormat(newValue, originalRaw); + + // Update the replacement with the properly formatted date + replacement.value = formattedDate; + replacement.raw = formattedDate.toISOString(); + replacement.loc.end.column = replacement.loc.start.column + replacement.raw.length; + } } // Preserve array trailing comma format @@ -233,7 +264,7 @@ function preserveFormatting(existing: Value, replacement: Value): void { * const result = applyChanges(originalDoc, updatedDoc, changes, format); * ``` */ -function applyChanges(original: Document, updated: Document, changes: Change[], format: TomlFormat): Document { +function applyChanges(original: Document, updated: Document, changes: Change[], format: TomlFormat, temporal: boolean = false): Document { // Potential Changes: // // Add: Add key-value to object, add item to array @@ -266,7 +297,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // regenerate a fresh TableArray from the JS value. if (is_table_array && !isTableArray(child)) { const tableArrayKey = parent_path.filter(p => typeof p === 'string') as string[]; - const updated_js = toJS(updated.items); + const updated_js = toJS(updated.items, '', { temporal }); let jsValue: any = updated_js; for (const k of change.path) jsValue = jsValue?.[k]; if (jsValue !== undefined) { @@ -450,7 +481,7 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // node and `replacement` (from the updated document) may be an InlineItem or KV that does // not carry the full scope. Simply splicing it into the Document would lose the scope. // Get the JS value at change.path and regenerate a fresh KV + parent table from scratch. - const updated_js = toJS(updated.items); + const updated_js = toJS(updated.items, '', { temporal }); let jsValue: any = updated_js; for (const key of change.path) { jsValue = jsValue?.[key]; diff --git a/src/temporal-global.d.ts b/src/temporal-global.d.ts new file mode 100644 index 00000000..443e8884 --- /dev/null +++ b/src/temporal-global.d.ts @@ -0,0 +1,21 @@ +/** + * Minimal type declaration for the Temporal global API (Stage 4). + * The full types are available from @js-temporal/polyfill, but we only + * need enough to avoid TS2304 errors when casting `(Temporal as any)`. + * + * When the TS lib includes Temporal natively, this file can be removed. + */ +declare var Temporal: { + PlainDate: { + from(value: string): any; + }; + PlainTime: { + from(value: string): any; + }; + PlainDateTime: { + from(value: string): any; + }; + ZonedDateTime: { + from(value: string): any; + }; +}; diff --git a/src/to-js.ts b/src/to-js.ts index 7277cc51..23349759 100644 --- a/src/to-js.ts +++ b/src/to-js.ts @@ -2,6 +2,7 @@ import { Value, NodeType, TreeNode, CST, InlineTable, Table, TableArray, KeyValu import { last, blank, isDate, has } from './utils'; import ParseError from './parse-error'; import { IntegersAsBigInt } from './parse-options'; +import { LocalDate, LocalTime, LocalDateTime, OffsetDateTime } from './date-format'; function integerFromRaw(raw: string, mode: IntegersAsBigInt = 'asNeeded'): number | bigint { const compact = raw.replace(/_/g, ''); @@ -54,14 +55,66 @@ function trackNestedInlineTables(inlineTable: InlineTable, basePath: string[], i } } +/** + * Converts a custom Date subclass value to the corresponding Temporal object. + * Does NOT check for Temporal availability — callers must guard that. + */ +function dateValueToTemporal(value: Date): any { + // If the value is already a Temporal object (e.g. from a CST generated by + // parseJS with Temporal input), return it directly — no conversion needed. + if (value.constructor?.name?.startsWith?.('Temporal.') || + value.constructor?.name === 'PlainDate' || + value.constructor?.name === 'PlainTime' || + value.constructor?.name === 'PlainDateTime' || + value.constructor?.name === 'ZonedDateTime') { + return value; + } + + const T = (globalThis as any).Temporal; + // LocalDate → Temporal.PlainDate + if (value instanceof LocalDate) { + return T.PlainDate.from(value.toISOString()); + } + // LocalTime → Temporal.PlainTime + if (value instanceof LocalTime) { + return T.PlainTime.from(value.toISOString()); + } + // LocalDateTime → Temporal.PlainDateTime + if (value instanceof LocalDateTime) { + // toISOString() uses a space separator when the original TOML did + // (useSpaceSeparator flag). Temporal.from() requires T. + return T.PlainDateTime.from(value.toISOString().replace(' ', 'T')); + } + // OffsetDateTime → Temporal.ZonedDateTime + if (value instanceof OffsetDateTime) { + // Same: toISOString() uses a space separator when the original TOML did. + // Normalize to T before extracting the offset and calling .from(). + const iso = value.toISOString().replace(' ', 'T'); + const offsetMatch = iso.match(/([+-]\d{2}:\d{2}|Z)$/); + const offset = offsetMatch ? offsetMatch[1] : 'Z'; + const plainIso = iso.replace(/([+-]\d{2}:\d{2}|Z)$/, ''); + const tz = offset === 'Z' ? '+00:00' : offset; + return T.ZonedDateTime.from(`${plainIso}${offset}[${tz}]`); + } + // Fallback: native Date or unrecognized Date subclass → Temporal.PlainDateTime + return T.PlainDateTime.from(value.toISOString().replace('Z', '')); +} + /** * Converts the given CST to a JavaScript object. * * @param cst The Concrete Syntax Tree to convert. * @param input The original input string (used for error reporting). + * @param opts.integersAsBigInt Controls integer representation. + * @param opts.temporal When true, date/time values are returned as Temporal objects. * @returns The JavaScript object representation of the CST. */ -export default function toJS(cst: CST, input: string = '', integersAsBigInt: IntegersAsBigInt = 'asNeeded'): any { +export default function toJS( + cst: CST, + input: string = '', + opts: { integersAsBigInt?: IntegersAsBigInt; temporal?: boolean } = {} +): any { + const { integersAsBigInt = 'asNeeded', temporal = false } = opts; const result = blank(); const tables: Set = new Set(); const table_arrays: Set = new Set(); @@ -158,7 +211,7 @@ export default function toJS(cst: CST, input: string = '', integersAsBigInt: Int let value; try { - value = toValue(node.value, integersAsBigInt); + value = toValue(node.value, { integersAsBigInt, temporal }); } catch (err) { const e = err as Error; throw new ParseError(input, node.value.loc.start, e.message); @@ -180,9 +233,16 @@ export default function toJS(cst: CST, input: string = '', integersAsBigInt: Int /** * Converts a TOML CST value node to a JavaScript value. * @param node The TOML CST value node. + * @param opts Options object: + * @param opts.integersAsBigInt Controls integer representation (`'asNeeded'` | `true` | `false`). + * @param opts.temporal When `true`, date/time values are returned as Temporal objects * @returns The corresponding JavaScript value. */ -export function toValue(node: Value, integersAsBigInt: IntegersAsBigInt = 'asNeeded'): any { +export function toValue( + node: Value, + opts: { integersAsBigInt?: IntegersAsBigInt; temporal?: boolean } = {} +): any { + const { integersAsBigInt = 'asNeeded', temporal = false } = opts; switch (node.type) { case NodeType.InlineTable: const result = blank(); @@ -191,7 +251,7 @@ export function toValue(node: Value, integersAsBigInt: IntegersAsBigInt = 'asNee node.items.forEach(({ item }) => { const key = item.key.value; - const value = toValue(item.value, integersAsBigInt); + const value = toValue(item.value, { integersAsBigInt, temporal }); // Check for duplicate keys and conflicting key paths const full_key = joinKey(key); @@ -234,9 +294,18 @@ export function toValue(node: Value, integersAsBigInt: IntegersAsBigInt = 'asNee return result; case NodeType.InlineArray: - return node.items.map(item => toValue(item.item as Value, integersAsBigInt)); + return node.items.map(item => toValue(item.item as Value, { integersAsBigInt, temporal })); case NodeType.DateTime: + if (temporal) { + if (typeof (globalThis as any).Temporal === 'undefined') { + throw new Error( + 'Temporal API is not available in this runtime. ' + + 'Set temporal: false or use a runtime with Temporal support.' + ); + } + return dateValueToTemporal(node.value); + } // Preserve TOML date/time custom classes so format is retained when // round-tripping through stringify() (e.g. date-only, time-only, local vs offset). // These classes extend Date, so JS users can still treat them as Dates. diff --git a/src/toml-document.ts b/src/toml-document.ts index 8e19e158..486cd319 100644 --- a/src/toml-document.ts +++ b/src/toml-document.ts @@ -16,6 +16,7 @@ export class TomlDocument { private _currentTomlString: string; private _format: TomlFormat; private _integersAsBigInt: IntegersAsBigInt; + private _temporal: boolean; /** * Initializes the TomlDocument with TOML source, parsing it into a CST. @@ -26,6 +27,7 @@ export class TomlDocument { * @param tomlSource - The TOML source to parse (string or raw UTF-8 bytes) * @param options - Optional parse options * @param options.integersAsBigInt - Controls bigint vs number for TOML integers + * @param options.temporal - When true, returns Temporal objects for date/time values */ constructor(tomlSource: string | Uint8Array, options?: ParseOptions) { const sourceString = typeof tomlSource === 'string' @@ -36,6 +38,7 @@ export class TomlDocument { this._currentTomlString = tomlString; this._cst = Array.from(parseTOML(tomlString)); this._integersAsBigInt = options?.integersAsBigInt ?? 'asNeeded'; + this._temporal = options?.temporal ?? false; // Auto-detect formatting preferences from the original TOML string this._format = TomlFormat.autoDetectFormatWithCst(sourceString, this._cst); } @@ -48,8 +51,15 @@ export class TomlDocument { * Returns the JavaScript object representation of the TOML document. */ get toJsObject(): any { - const jsObject = toJS(this._cst, this._currentTomlString, this._integersAsBigInt); - // Convert custom date classes to regular JavaScript Date objects + const jsObject = toJS(this._cst, this._currentTomlString, { + integersAsBigInt: this._integersAsBigInt, + temporal: this._temporal + }); + // When temporal is enabled, Temporal objects are already returned — no conversion needed. + // When temporal is disabled, convert custom date classes to regular JavaScript Date objects. + if (this._temporal) { + return jsObject; + } return convertCustomDateClasses(jsObject); } diff --git a/src/utils.ts b/src/utils.ts index 7106e408..5d63e633 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -41,8 +41,66 @@ export function isDate(value: any): value is Date { return Object.prototype.toString.call(value) === '[object Date]'; } +/** + * Duck-type check for Temporal API objects. + * + * Works with both the native Temporal (constructor names like + * "Temporal.PlainDate") and the @js-temporal/polyfill (constructor + * names like "PlainDate"). Avoids instanceof issues across realms. + * + * Only the four TOML-relevant types are checked: + * PlainDate, PlainTime, PlainDateTime, ZonedDateTime. + */ +const TEMPORAL_TYPE_NAMES = new Set([ + 'Temporal.PlainDate', 'Temporal.PlainTime', 'Temporal.PlainDateTime', 'Temporal.ZonedDateTime', + 'PlainDate', 'PlainTime', 'PlainDateTime', 'ZonedDateTime' +]); + +export function isTemporal(value: any): boolean { + return value != null + && typeof value === 'object' + && TEMPORAL_TYPE_NAMES.has(value.constructor?.name) + && typeof (value as any).equals === 'function'; +} + +/** + * Converts a Temporal object to its TOML-compatible string representation. + * + * For ZonedDateTime, this strips the IANA timezone annotation (e.g. [Asia/Kolkata]) + * and normalizes +00:00 to Z, since TOML only supports offset-based timezones. + * For other Temporal types, this is equivalent to toString(). + */ +export function temporalToTomlString(value: any): string { + const name: string = value.constructor?.name ?? ''; + + if (name === 'Temporal.ZonedDateTime' || name === 'ZonedDateTime') { + // Reject IANA timezone annotations — TOML only supports offsets. + const full = value.toString(); + const bracketMatch = full.match(/\[(.+)\]$/); + if (bracketMatch && !/^[+-]\d{2}:\d{2}$/.test(bracketMatch[1])) { + throw new Error( + `ZonedDateTime with IANA timezone "${full}" cannot be represented in TOML. ` + + 'TOML only supports offset-based timezones (+05:30, Z).' + ); + } + // Strip bracket annotation, then normalize +00:00 offset suffix to Z + return full.replace(/\[.*\]$/, '').replace(/(\+00:00)$/, 'Z'); + } + + const raw = value.toString(); + // Reject bracket annotations on non-ZonedDateTime types too + // (non-ISO calendars like [u-ca=...] are not valid TOML). + if (/\[.*\]/.test(raw)) { + throw new Error( + `Temporal value contains unsupported annotation: "${raw}". ` + + 'TOML only supports ISO 8601 calendar.' + ); + } + return raw; +} + export function isObject(value: any): boolean { - return value && typeof value === 'object' && !isDate(value) && !Array.isArray(value); + return value && typeof value === 'object' && !isDate(value) && !isTemporal(value) && !Array.isArray(value); } export function isIterable(value: any): value is Iterable { @@ -95,7 +153,16 @@ export function arraysEqual(a: TItem[], b: TItem[]): boolean { } export function datesEqual(a: any, b: any): boolean { - return isDate(a) && isDate(b) && a.toISOString() === b.toISOString(); + // Temporal objects: compare via toString(). Two ZonedDateTime values + // with different IANA zones are NOT the same even if their offsets match. + if (isTemporal(a) && isTemporal(b)) { + return a.toString() === b.toString(); + } + // Custom Date subclasses: compare via toISOString() + if (isDate(a) && isDate(b)) { + return a.toISOString() === b.toISOString(); + } + return false; } export function stableStringify(object: any): string { @@ -107,6 +174,12 @@ export function stableStringify(object: any): string { return `{${key_values.join(',')}}`; } else if (Array.isArray(object)) { return `[${object.map(stableStringify).join(',')}]`; + } else if (isTemporal(object)) { + // Temporal objects use toString() for a stable ISO representation + return JSON.stringify(object.toString()); + } else if (isDate(object)) { + // Custom Date subclasses use toISOString() + return JSON.stringify(object.toISOString()); } else { return JSON.stringify(object); }