diff --git a/package.json b/package.json
index 5b617ca5..9171ddf2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tempo-monorepo",
- "version": "2.2.2",
+ "version": "2.2.3",
"private": true,
"description": "Magma Computing Monorepo",
"repository": {
diff --git a/packages/library/package.json b/packages/library/package.json
index 6cf917d5..293355b4 100644
--- a/packages/library/package.json
+++ b/packages/library/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
- "version": "2.2.2",
+ "version": "2.2.3",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts
index 52c2505a..646da620 100644
--- a/packages/tempo/.vitepress/config.ts
+++ b/packages/tempo/.vitepress/config.ts
@@ -18,15 +18,16 @@ export default defineConfig({
nav: [
{ text: 'Guide', link: '/README' },
{ text: 'API', link: '/doc/tempo.api' },
- { text: 'Releases', link: '/doc/releases/versions' }
+ { text: 'Releases', link: '/doc/releases/' }
],
sidebar: [
{
text: 'Getting Started',
items: [
{ text: 'Introduction', link: '/README' },
+ { text: 'Cookbook', link: '/doc/tempo.cookbook' },
{ text: 'Migration Guide', link: '/doc/migration-guide' },
- { text: 'Version History', link: '/doc/releases/versions' }
+ { text: 'Release Notes', link: '/doc/releases/' }
]
},
{
@@ -34,11 +35,21 @@ export default defineConfig({
items: [
{ text: 'Configuration', link: '/doc/tempo.config' },
{ text: 'Modularity', link: '/doc/tempo.modularity' },
- { text: 'Shorthand Engine', link: '/doc/tempo.shorthand' },
+ { text: 'Layout Patterns', link: '/doc/tempo.layout' },
{ text: 'Terms System', link: '/doc/tempo.term' },
{ text: 'Ticker Plugin', link: '/doc/tempo.ticker' }
]
},
+ {
+ text: 'Advanced Reference',
+ items: [
+ { text: 'API Reference', link: '/doc/tempo.api' },
+ { text: 'Types System', link: '/doc/tempo.types' },
+ { text: 'Shorthand Engine', link: '/doc/tempo.shorthand' },
+ { text: 'Weekday Engine', link: '/doc/tempo.weekday' },
+ { text: 'Debugging', link: '/doc/tempo.debugging' }
+ ]
+ },
{
text: 'Architecture & Internals',
items: [
@@ -48,13 +59,30 @@ export default defineConfig({
{ text: 'Performance Benchmarks', link: '/doc/tempo.benchmarks' }
]
},
+ {
+ text: 'Utility Library',
+ items: [
+ { text: 'Library Overview', link: '/doc/tempo.library' },
+ { text: 'Enumerators', link: '/doc/tempo.enumerators' },
+ { text: 'Serializers', link: '/doc/tempo.serializers' },
+ { text: 'Decorators', link: '/doc/tempo.decorators' },
+ { text: 'Advanced Promises (Pledge)', link: '/doc/tempo.pledge' },
+ ]
+ },
{
text: 'Ecosystem',
items: [
{ text: 'Contribution Guide', link: '/CONTRIBUTING' },
{ text: 'Comparison', link: '/doc/comparison' },
+ { text: 'Tempo vs Temporal', link: '/doc/tempo-vs-temporal' },
{ text: 'Project Vision', link: '/doc/vision' }
]
+ },
+ {
+ text: 'Services & Support',
+ items: [
+ { text: 'Professional Services', link: '/doc/commercial' }
+ ]
}
],
socialLinks: [
diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md
index 77e87f66..202980c9 100644
--- a/packages/tempo/CHANGELOG.md
+++ b/packages/tempo/CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.2.3] - 2026-04-20
+
+### Added
+- **Modular Parse Engine**: Successfully decoupled internal parsing logic into `ParseModule`, enabling standalone parsing support and reducing core class complexity.
+- **Carousel Accessibility**: Added ARIA roles, labels, and keyboard controls (Arrow keys) to the documentation carousel to improve screen reader and keyboard user experience.
+- **Layout Snippets**: Introduced layout patterns with snippet-based customization for more flexible date formatting.
+
+### Changed
+- **HMR Resilience**: Hardened the development-mode registry workaround by binding the original `Map.has` method and moving extension registration before class initialization, resolving "read only property" errors during hot reloads.
+- **Modular Registration**: Enforced standard `defineInterpreterModule` clash guards for all core modules (Parse, Mutate, Duration) to prevent registry collisions.
+
+### Fixed
+- **Resource Management**: Fixed interval leaks in the documentation clock by implementing proper cleanup for the fallback heartbeat timer during unmounting and visibility changes.
+- **Initialization Stability**: Added a sentinel guard and optimized `initPromise` handling to prevent redundant error logging and failed awaits during page visibility transitions.
+- **Mutation Engine Hardening**: Corrected preserves `state.options` and the `mutateDepth` recursion guard across all instance creation paths in `MutateModule`.
+- **Fluent Chaining Fallbacks**: Hardened `until()` and `since()` calls with explicit host-instance fallbacks to preserve fluent chaining when modules are missing in "catch" mode.
+
## [2.2.2] - 2026-04-18
### Fixed
diff --git a/packages/tempo/LICENSE b/packages/tempo/LICENSE
new file mode 100644
index 00000000..dd7db2d9
--- /dev/null
+++ b/packages/tempo/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Magma Computing
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/tempo/README.md b/packages/tempo/README.md
index 7fbd7e6f..4d3cf3af 100644
--- a/packages/tempo/README.md
+++ b/packages/tempo/README.md
@@ -5,7 +5,7 @@
-
Tempo
+
Tempo
The Professional Date-Time Library for the Temporal API
@@ -63,13 +63,15 @@ Ideal for organizations looking to move away from **Moment.js**, **Day.js**, or
For those building complex, time-sensitive systems (such as financial platforms, scheduling engines, or global logistics trackers) that demand the precision of Temporal combined with a premium, type-safe developer experience.
## 📦 Installation
+### 💻 on the Server (or Bundler)
+For Node.js, Bun, Deno, or projects using a bundler (Vite, Webpack, etc.), install via npm:
+
```bash
npm install @magmacomputing/tempo
```
-### 💻 on the Server
Tempo is a native ESM package. In Node.js (20+), simply import the class.
-Node.js, Bun and Deno support native ESM out of the box.
+Node.js, Bun, and Deno support native ESM out of the box.
```javascript
import { Tempo } from '@magmacomputing/tempo';
@@ -79,34 +81,58 @@ console.log(t.format('{dd} {mon} {yyyy}'));
```
### 🌐 in the Browser (Import Maps)
-Since Tempo is a native ESM package, you can use it directly in modern browsers using `importmap`:
+Since Tempo is a native ESM package, you can use it directly in modern browsers using `importmap`. The **bundle** entrypoint includes all standard modules pre-registered, but requires a separate `Temporal` polyfill for current browser environments.
```html
```
### 📦 in the Browser (Script Tag)
-For environments without `importmap` support or simple prototypes, use the bundled version:
+For environments without `importmap` support or simple prototypes, use the global bundle. This automatically attaches the `Tempo` class to the `window` object.
```html
-
+
```
+### 🧪 Advanced: Granular ESM (Lite Build)
+For maximum performance, you can use the lean **Core** engine and opt-in to specific modules. This prevents loading unused logic and keeps your production bundle minimal.
+
+```html
+
+
+```
+
---
## 📚 Documentation
diff --git a/packages/tempo/bin/setup.polyfill.ts b/packages/tempo/bin/setup.polyfill.ts
deleted file mode 100644
index 58d8dfab..00000000
--- a/packages/tempo/bin/setup.polyfill.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Test/Dev Polyfill Bootstrap
- *
- * This file loads the @js-temporal/polyfill into
- * globalThis so that the passive assertion in
- * temporal.polyfill.ts succeeds.
- *
- * This is the "bring your own polyfill" entry point
- * for development and testing environments that do
- * not yet have native Temporal support.
- */
-import { Temporal } from '@js-temporal/polyfill';
-
-if (typeof globalThis.Temporal === 'undefined') {
- Object.defineProperty(globalThis, 'Temporal', {
- value: Temporal,
- enumerable: false,
- configurable: true,
- writable: true,
- });
-}
diff --git a/packages/tempo/bin/temporal-polyfill.ts b/packages/tempo/bin/temporal-polyfill.ts
new file mode 100644
index 00000000..e94faa40
--- /dev/null
+++ b/packages/tempo/bin/temporal-polyfill.ts
@@ -0,0 +1,10 @@
+import { Temporal } from '@js-temporal/polyfill';
+
+if (typeof globalThis.Temporal === 'undefined') {
+ Object.defineProperty(globalThis, 'Temporal', {
+ value: Temporal,
+ enumerable: false,
+ configurable: true,
+ writable: true,
+ });
+}
diff --git a/packages/tempo/doc/releases/index.md b/packages/tempo/doc/releases/index.md
new file mode 100644
index 00000000..cecc728c
--- /dev/null
+++ b/packages/tempo/doc/releases/index.md
@@ -0,0 +1,15 @@
+# 🚀 Releases
+
+Explore the evolution of Tempo through its version history.
+
+- [Version 2.x (Current)](./v2.x) - Modular architecture, Shorthand engine, and Ticker stability.
+- [Version 1.x (Legacy)](./v1.x) - Initial public release and Temporal polyfill integration.
+- [Version 0.x (Legacy)](./v0.x) - Initial release.
+
+---
+
+## Release Strategy
+Tempo follows [Semantic Versioning](https://semver.org/).
+- **Major**: Breaking changes and architectural shifts.
+- **Minor**: New features and significant enhancements.
+- **Patch**: Bug fixes and performance optimizations.
diff --git a/packages/tempo/doc/releases/v0.x.md b/packages/tempo/doc/releases/v0.x.md
new file mode 100644
index 00000000..01dfdd40
--- /dev/null
+++ b/packages/tempo/doc/releases/v0.x.md
@@ -0,0 +1,31 @@
+# 📜 Version 0.x History
+
+## [v0.3.0] - 2025-07-31
+### New Features
+
+Introduced an immutable property decorator and a deep-freeze utility for objects and arrays.
+Added a secure, default configuration module for date/time parsing.
+Extended global browser support for the Temporal API via polyfill.
+### Bug Fixes
+
+Corrected the enumerable decorator to ensure proper closure.
+### Refactor
+
+Centralised and secured date/time parsing constants and layouts.
+Improved typing and immutability for enums and configuration objects.
+Updated logging and state management to use object-based constants instead of enums.
+### Documentation
+
+Enhanced inline documentation and added detailed usage examples for enum utilities.
+### Chores
+
+Updated development dependencies to latest versions.
+Reformatted TypeScript configuration for clarity.
+
+## [v0.2.0] - 2024-10-30
+### New Features
+
+Updated the version of the Tempo class to 0.2.0, ensuring users have access to the latest enhancements.
+### Bug Fixes
+
+Resolved versioning discrepancies in the Default configuration object.
\ No newline at end of file
diff --git a/packages/tempo/doc/releases/v1.x.md b/packages/tempo/doc/releases/v1.x.md
new file mode 100644
index 00000000..a860c2ad
--- /dev/null
+++ b/packages/tempo/doc/releases/v1.x.md
@@ -0,0 +1,59 @@
+# 📜 Version 1.x History
+
+## [v1.1.3] - 2026-03-31
+### New Features
+
+Browser: persistent storage wrapper, multi‑tap gesture helper, geolocation + maps/address utilities, simple alert/prompt/confirm helpers.
+Server: sandboxed temp‑file API, HTTP helpers with timeouts, Base64 encode/decode, JWT payload decoder.
+Tempo v2.0.0: lazy initialization, unified term math/format tokens (#{...}), anchor/term utilities and faster startup.
+### Documentation
+
+Expanded API/type docs, architecture, benchmarks, migration and cookbook guidance.
+### Branding
+
+Package scope renamed to @magmacomputing/*; new build/release scripts and updated TypeScript/test configs.
+
+## [v1.1.2] - 2026-03-23
+### New Features
+
+Unified plugin system via Tempo.load() for plugins, term plugins, and discovery configuration.
+Made Pledge thenable and awaitable via .then() method support.
+Enhanced makeTemplate() with safe placeholder substitution instead of code evaluation.
+### Bug Fixes
+
+Improved asCurrency() to properly coerce string inputs.
+Fixed constructor error handling to throw instead of returning empty objects.
+### Documentation
+
+Added Target Audience section describing intended users and migration scenarios.
+Expanded Node.js documentation with native subpath import details.
+
+## [v1.1.1] - 2026-03-22
+### New Features
+
+Plugin-based extension architecture for extending Tempo functionality
+Automatic plugin discovery via static Tempo.discover() method
+New subpath exports for enums, serialisation, Pledge utility, and ticker plugin
+### Changed
+
+Ticker functionality now available as optional plugin; requires explicit installation
+Selective immutability for decorated classes instead of full object freeze
+Core methods (format, add, set) are now protected from overwrites
+### Documentation
+
+Comprehensive overhaul reflecting new plugin architecture
+Dedicated Pledge utility documentation added
+
+## [v1.1.0] - 2026-03-19
+### New Features
+
+Introduced Tempo.ticker() — reactive clock streams via async-generator (for await...of) or callback subscription with stop control.
+### Documentation
+
+Added a dedicated ticker guide and updated Tempo docs with examples and API reference.
+### Tests
+
+Added unit tests covering callback mode, async-iterator mode and input validation.
+### Chores
+
+Bumped package version to 1.1.0.
\ No newline at end of file
diff --git a/packages/tempo/doc/releases/versions.md b/packages/tempo/doc/releases/v2.x.md
similarity index 74%
rename from packages/tempo/doc/releases/versions.md
rename to packages/tempo/doc/releases/v2.x.md
index ec137411..27a4a5a7 100644
--- a/packages/tempo/doc/releases/versions.md
+++ b/packages/tempo/doc/releases/v2.x.md
@@ -1,4 +1,22 @@
-# 📜 Version History
+# 📜 Version 2.x History
+
+## [v2.2.3] - 2026-04-20
+### New Features
+
+- Modular parse engine and standalone parsing support
+- Layout patterns with snippet-based customization
+- Added release notes pages and added MIT license
+### Bug Fixes
+
+- Preserve parse results across cloned instances
+- Fixed timezone/calendar drift during complex mutations
+### Documentation
+
+- Reorganized docs navigation with Cookbook, Advanced Reference, Utility Library, Services & Support
+- Expanded API/constructor docs and custom-enum guidance
+### Chores
+
+- Improved module-loading diagnostics and general doc formatting cleanup
## [v2.2.2] - 2026-04-18
### 🛡️ Registry Stabilization
@@ -19,10 +37,22 @@
- Harmonized branding across README and documentation sites with "Tempo Blue" styling.
- Fixed routing and asset resolution for GitHub Pages deployments using VitePress base-path helpers.
+### 🧬 Mutation Engine & State Preservation
+- Stabilized the mutation engine (`.add()`, `.set()`) to correctly accumulate and preserve parse results across instance clones.
+- Implemented robust parse result propagation in `MutateModule` using the internal `state.matches` registry.
+- Standardized cross-module state access using the `[sym.$Internal]` symbol, enabling safe cloning without violating private encapsulation.
+- Resolved timezone and calendar drift during complex mutations by enforcing authoritative state-merging in the mutation pipeline.
+
+### 🧩 Modular Parse Engine
+- Successfully decoupled internal parsing logic into `ParseModule`, reducing core class complexity.
+- Implemented a hybrid static `Tempo.parse` configuration/method pattern to maintain full backward compatibility while supporting modular delegation.
+- Hardened the `interpret` utility to ensure reliable method dispatching even when modules are loaded lazily or via HMR.
+
### ⚙️ CI/CD & Tooling
- Upgraded GitHub Actions pipeline to **Node.js 24** to resolve deprecation warnings.
- Restored separate "Full" and "Core" test suite isolation in local and workspace Vitest configurations.
- Standardized documentation build order to ensure artifacts are compiled before generation.
+- Achieved **100% Test Pass Rate** (384/384) across all environments and distribution bundles.
## [v2.1.3] - 2026-04-18
### New Features
diff --git a/packages/tempo/doc/tempo-vs-temporal.md b/packages/tempo/doc/tempo-vs-temporal.md
index 99687bfa..aa9b516b 100644
--- a/packages/tempo/doc/tempo-vs-temporal.md
+++ b/packages/tempo/doc/tempo-vs-temporal.md
@@ -67,7 +67,6 @@ From that point, the plugin is available to new Tempo instances.
See the section on [plugin](tempo.term.md) for more information.
-
```typescript
const t = new Tempo();
const isWeekend = t.term.isWeekend; // through plugin
diff --git a/packages/tempo/doc/tempo.api.md b/packages/tempo/doc/tempo.api.md
index ed42e15b..5d996cbe 100644
--- a/packages/tempo/doc/tempo.api.md
+++ b/packages/tempo/doc/tempo.api.md
@@ -9,6 +9,25 @@ This document provides a comprehensive technical reference for the `Tempo` class
---
+## 🏗️ Constructor
+
+You can instantiate `Tempo` in several ways:
+
+- **`new Tempo()`**: Defaults to current time ("now").
+- **`new Tempo(dateTime)`**: Parses a date-time value.
+- **`new Tempo(dateTime, options)`**: Parses with specific configuration.
+- **`new Tempo(options)`**: Defaults to "now" with specific configuration.
+
+### Valid `dateTime` Types:
+- **`string`**: ISO 8601, natural language ("tomorrow", "next Friday"), or custom patterns.
+- **`number`**: Unix timestamps in milliseconds (default) or microseconds.
+- **`BigInt`**: Unix timestamps in nanoseconds.
+- **`Date`**: Standard JavaScript `Date` object.
+- **`Tempo`**: Clones another Tempo instance.
+- **`Temporal.*`**: Any native Temporal object (ZonedDateTime, PlainDate, etc.).
+
+---
+
## 🏗️ Static Methods
### `Tempo.init(options?: Tempo.Options)`
@@ -39,7 +58,6 @@ Compares two `Tempo` instances or date-time values for sorting.
- **Returns:** `Tempo.Duration`
- **Example:** `Tempo.duration('P1Y')` or `Tempo.duration({ months: 2 })`
-
### `Tempo.now()`
Returns the current Unix epoch in nanoseconds as a `BigInt`.
@@ -178,3 +196,9 @@ Returns a `Temporal.PlainDateTime` representation.
- `fmt`: Registry of pre-calculated strings for all standard formats. (Note: These are enumerable for easy discovery).
- `config`: The effective configuration for this specific instance (Note: `scope`, `anchor`, and `value` are excluded from the public object).
- `parse`: The parsing rules and lineage for this instance.
+
+---
+
+> [!TIP]
+> **Looking for the full technical details?**
+> For an exhaustive, auto-generated reference of every property, internal type, and class member, see our [Full Technical API Reference](./api/README.md).
diff --git a/packages/tempo/doc/tempo.constructor.md b/packages/tempo/doc/tempo.constructor.md
deleted file mode 100644
index 9d952acd..00000000
--- a/packages/tempo/doc/tempo.constructor.md
+++ /dev/null
@@ -1,32 +0,0 @@
-You instantiate a Tempo in a number of ways.
-
-via the Tempo constructor()
-a. new Tempo()
-b. new Tempo(dateTime)
-c. new Tempo(dateTime, options)
-d. new Tempo(options)
-
-or via the static method Tempo methods
-a. Tempo.now()
-b. Tempo.from(dateTime, options)
-
-
-The {dateTime} argument is one of the following types:
-- `string`: ISO strings, natural language relative strings, or custom patterns.
-- `number`: Unix timestamps in milliseconds (default) or microseconds.
-- `BigInt`: Unix timestamps in nanoseconds.
-- `Date`: Standard JavaScript Date object.
-- `Tempo`: Another Tempo instance (clones the instance).
-- `Function`: A dynamic resolver (max depth 5; returns any valid DateTime).
-- `Temporal.*`: Various Temporal objects (ZonedDateTime, PlainDate, etc.).
-- `void` | `null`: Defaults to the current time ("now").
-
-The {options} argument (in either the first or second parameter) is a JSON object that refines this instance.
-(see 'Options')
-
----
-
-Tempo will interpret the {dateTime} depending on its type:
-- **string**: The parsing engine attempts to match the string against a known set of patterns (see [Parsing](file:///home/michael/Project/tempo/doc/tempo.layout.md)).
-- **number**: First checks if it is a match against a known layout (see [Layouts](file:///home/michael/Project/tempo/doc/tempo.layout.md)). If not, it is Interpreted as a Unix timestamp. The precision (ms or us) is configurable via `Tempo.init()`.
-- **Temporal objects**: Directly converted to a `Tempo` instance, preserving time zone and calendar if applicable.
\ No newline at end of file
diff --git a/packages/tempo/doc/tempo.enumerators.md b/packages/tempo/doc/tempo.enumerators.md
index c7103dfa..31e3bee2 100644
--- a/packages/tempo/doc/tempo.enumerators.md
+++ b/packages/tempo/doc/tempo.enumerators.md
@@ -4,8 +4,6 @@ Tempo uses a custom `enumify` utility to define enumerations rather than relying
This guide explains how they are defined, how you use them as a consumer of the `Tempo` library, and why this design pattern was chosen.
----
-
## 1. How Tempo Enums are Defined
Tempo's core enumerators (like Weekdays, Months, Seasons) are built using the exported `enumify` function.
@@ -34,8 +32,6 @@ After defining the enumify object, simple TypeScript helper aliases pull out the
export type SEASON = ValueOf; // Type: 'summer' | 'autumn' | 'winter' | 'spring'
```
----
-
## 2. Using Enums Outside of Tempo
For consumers of the library, these enumerations are exposed cleanly as **static getters** on the core `Tempo` class. They are also available as a namespace object from the 'barrel' (index.ts) export `enums`.
@@ -49,6 +45,24 @@ import { Tempo } from '@magmacomputing/tempo';
const direction = Tempo.COMPASS.North; // 'north'
const monthIndex = Tempo.MONTH.Feb; // 2 (since 'All' was index 0)
```
+
+## 3. Creating Custom Enums
+
+You can utilize the same `enumify` engine for your own application logic by importing it from the library subpath. This is particularly useful for maintaining consistent data patterns and iteration capabilities throughout your project.
+
+### Example Usage
+
+```typescript
+import { enumify } from '@magmacomputing/tempo/library';
+
+// 1. Define your Enum
+export const STATUS = enumify(['Pending', 'Active', 'Resolved', 'Archived']);
+
+// 2. Use the built-in methods
+const allKeys = STATUS.keys(); // ['Pending', 'Active', 'Resolved', 'Archived']
+const isActive = STATUS.has('Active'); // true
+const value = STATUS.Resolved; // 2
+```
or alternatively, directly from the 'enums' export in the package.json
```typescript
import { enums } from '@magmacomputing/tempo/enums';
@@ -78,9 +92,7 @@ const keyName = Tempo.MONTH.keyOf(2); // 'Feb'
const customStrings = Tempo.WEEKDAY.map(([key, val]) => `${key} is day ${val}`);
```
----
-
-## 3. How They Are Used Inside Tempo
+## 4. How They Are Used Inside Tempo
Internally, the `Tempo` logic relies heavily on these enumerators. This gives the parsing and formatting engines guaranteed type-safety and robust lookup dictionaries.
@@ -88,9 +100,7 @@ For instance, the `.format()` logic can map tokens efficiently, and parser confi
The overarching design ensures the library stays strongly typed, internally consistent, and protected against accidental runtime mutation via `Object.freeze()`.
----
-
-## 4. `enumify` vs. TypeScript `enum` (The Trade-Offs)
+## 5. `enumify` vs. TypeScript `enum` (The Trade-Offs)
TypeScript's native `enum` is one of the few TS features that generates structural runtime JavaScript, and it has known friction points in the JavaScript community.
diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/tempo.layout.md
index 63b694cc..7a865dfc 100644
--- a/packages/tempo/doc/tempo.layout.md
+++ b/packages/tempo/doc/tempo.layout.md
@@ -2,14 +2,54 @@
Tempo's parsing engine is driven by regular expression patterns and named capture-groups. By understanding and extending these layouts, you can teach Tempo to understand entirely new terminology, formats, and relative units.
-## Default Patterns
+## What is a Snippet?
-Tempo comes out-of-the-box with patterns to understand:
-- **ISO 8601** (`2024-05-20T10:00:00Z`)
-- **Dates** (`20-May`, `May 20`, `04/01/2026`)
-- **Times** (`10am`, `14:30:00.123`)
-- **Relative Events** (`today`, `tomorrow`, `yesterday`)
-- **Relative Periods** (`morning`, `noon`, `afternoon`)
+A **Snippet** is a pre-defined regex pattern that can be combined with other snippets to create a **Layout**.
+
+## What is a Layout?
+
+A **Layout** is a string that combines pre-defined **Snippets** and strings. When you provide a layout to `Tempo`, it is translated into an anchored, case-insensitive Regular Expression used to match and extract date-time values.
+
+## Available Snippets
+
+Snippets represent specific date or time units. When arranged in a layout, they form the blueprint for the parser:
+
+| Snippet | Description | Regex Match (approx) |
+| :--- | :--- | :--- |
+| `{yy}` | Year (2 or 4 digits) | `([0-9]{2})?[0-9]{2}` |
+| `{mm}` | Month (01-12, Jan-Dec, etc.) | `01-12` or names |
+| `{dd}` | Day (01-31) | `01-31` |
+| `{hh}` | Hour (00-24) | `00-24` |
+| `{mi}` | Minute (prefixed by `:`) | `:[0-5][0-9]` |
+| `{ss}` | Second (prefixed by `:`) | `:[0-5][0-9]` |
+| `{ff}` | Fraction (prefixed by `.`) | `\.[0-9]{1,9}` |
+| `{wkd}` | Weekday (Mon-Sun) | Name strings |
+| `{tzd}` | Time zone offset | `Z` or `±hh:mm` |
+| `{sep}` | Separator character | `/`, `-`, `.`, `,`, or ` ` |
+| `{mod}` | Modifier and count | `+`, `-`, `next`, `prev` |
+| `{unt}` | Time units | `year(s)`, `day(s)`, etc. |
+| `{evt}` | Event alias | `xmas`, `nye`, etc. |
+| `{per}` | Period alias | `midnight`, `noon`, etc. |
+| `{sfx}` | Time-pattern suffix | `T {tm} Z` |
+| `{brk}` | Zone/Calendar brackets | `[UTC][u-ca=iso8601]` |
+| `{www}` | Short weekday name | `Mon`, `Tue`, etc. |
+| `{nbr}` | Numeric value or word | `1`, `two`, etc. |
+| `{afx}` | Relative affix | `ago`, `hence`, `from now` |
+
+### Composite Snippets
+
+- `{dt}`: Matches a date (e.g., `{dd}{sep}{mm}`) OR an event alias `{evt}`.
+- `{tm}`: Matches a time (e.g., `{hh}{mi}`) OR a period alias `{per}`.
+
+## Built-in Layouts
+
+| Key | Layout | Description |
+| :--- | :--- | :--- |
+| `dtm` | `({dt}){sfx}?{brk}?` | Calendar/event and clock/period |
+| `dmy` | `{www}?{dd}{sep}?{mm}({sep}{yy})?{sfx}?{brk}?` | Day-month(-year) |
+| `mdy` | `{www}?{mm}{sep}?{dd}({sep}{yy})?{sfx}?{brk}?` | Month-day(-year) |
+| `ymd` | `{www}?{yy}{sep}?{mm}({sep}{dd})?{sfx}?{brk}?` | Year-month(-day) |
+| `unt` | `{nbr}{sep}?{unt}{sep}?{afx}` | Relative duration |
## Customizing Layouts
@@ -17,32 +57,34 @@ You can supply your own parsing tokens to Tempo globally via `Tempo.init()` or l
```typescript
Tempo.init({
- event: {
- 'birthday': '20 May',
- 'xmas': '25 Dec'
+ layout: {
+ 'myCustomFormat': '{dd}{sep}?{mm}{sep}?{yy}'
}
});
-const t = new Tempo('xmas'); // resolves to 25 Dec of the current year
+const t = new Tempo('20-05-2024'); // Parsed using 'myCustomFormat'
+```
+
+### Instance-Specific Layout
+
+```typescript
+const t = new Tempo('20240520', { layout: '{yy}{mm}{dd}' });
```
## Advanced Capture Groups
-When delving into Tempo's internal Regex patterns, the following named capture groups are utilized by the engine:
+When crafting raw regex, the following capture groups are used by the engine:
- `yy`, `mm`, `dd`: Year, Month, Day digits
- `hh`, `mi`, `ss`, `ff`: Hour, Minute, Second, Fractional digits
- `mer`: Meridiem (`am`, `pm`)
- `evt`: Event string offset
- `per`: Period string offset
- `unt`: Relative unit (e.g., `days`, `weeks`)
-- `mod`, `nbr`, `afx`, `sfx`: Modifiers, numbers, affixes, and suffixes for relative computations (e.g. `2 days ago`, `next Friday`)
---
-## Need a Custom Layout?
-
-Tempo's layout engine can interpret almost any date or time imaginable, but crafting robust regular expressions with strict named capture-groups requires precision.
+## Professional Services
-If your project involves specialized terminology, complex financial calendars, medical intervals, or legacy application log formats, the **Magma Computing** team offers professional services to design, build, and comprehensively test custom `Tempo` Layouts optimized precisely for your business needs.
+If your project involves specialized terminology, complex financial calendars, or legacy application log formats, the **Magma Computing** team offers professional services to design and test custom `Tempo` Layouts optimized for your business needs.
-Contact us at [Magma Computing](https://github.com/magmacomputing) to discuss extending Tempo for your organization.
+Contact us at [Magma Computing](https://github.com/magmacomputing).
diff --git a/packages/tempo/doc/tempo.library.md b/packages/tempo/doc/tempo.library.md
index 1a831afd..5daef2e8 100644
--- a/packages/tempo/doc/tempo.library.md
+++ b/packages/tempo/doc/tempo.library.md
@@ -55,4 +55,5 @@ Tempo provides a specialized wrapper around `Promise.withResolvers()` called `Pl
* **Resource Management:** Implements `Symbol.dispose` to automatically reject pending promises when they go out of scope, preventing deadlocks or memory leaks.
👉 **[Read the full Pledge Guide](./tempo.pledge.md)** for advanced usage with callbacks, debugging tags, and lifecycle management.
----
+
+
diff --git a/packages/tempo/doc/tempo.pattern.md b/packages/tempo/doc/tempo.pattern.md
deleted file mode 100644
index 8150f781..00000000
--- a/packages/tempo/doc/tempo.pattern.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# Custom Patterns
-
-`Tempo` will create a Regular Expressions from **Layout** strings. It will use these patterns to attempt to match and extract date-time values from an input-string.
-
-## What is a Snippet?
-
-A **Snippet** is a pre-defined regex pattern that can be combined with other snippets to create a **Layout**.
-
-## What is a Layout?
-
-A **Layout** is a string that combines pre-defined **Snippets** and strings. When you provide a layout to `Tempo`, it is translated into an anchored, case-insensitive Regular Expression used to match and extract date-time values.
-
-## Available Snippets
-
-Snippets are simple regex patterns that can be composed into a layout. They represent specific date or time units:
-
-| Snippet | Description | Regex Match (approx) |
-| :--- | :--- | :--- |
-| `{yy}` | Year (2 or 4 digits) | `([0-9]{2})?[0-9]{2}` |
-| `{mm}` | Month (01-12, Jan-Dec, January-December) | `01-12` or names |
-| `{dd}` | Day (01-31) | `01-31` |
-| `{hh}` | Hour (00-24) | `00-24` |
-| `{mi}` | Minute (prefixed by `:`) | `:[0-5][0-9]` |
-| `{ss}` | Second (prefixed by `:`) | `:[0-5][0-9]` |
-| `{ff}` | Fraction (prefixed by `.`) | `\.[0-9]{1,9}` |
-| `{wkd}` | Weekday (Mon-Sun, Monday-Sunday) | Name strings |
-| `{tzd}` | Time zone offset | `Z` or `±hh:mm` |
-| `{mer}` | Meridiem (AM/PM) | `am` or `pm` |
-| `{sep}` | Separator character | `/`, `-`, `.`, `,`, or ` ` |
-| `{mod}` | Modifier and optional count | `+`, `-`, `<`, `>`, `next`, `prev`, etc. |
-| `{nbr}` | Generic number or names (0-10) | `[0-9]+` / `zero-ten` |
-| `{unt}` | Time units (year, month, week, etc.) | `year(s)`, `day(s)`, etc. |
-| `{afx}` | Affix modifier | `ago`, `hence`, or `from now` |
-| `{sfx}` | Time suffix | Matches `T` or a space followed by a time pattern |
-| `{brk}` | Timezone/calendar brackets | `[Europe/London]`, `[u-ca=iso8601]` |
-
-### Composite Snippets
-
-Some snippets are auto-built from others:
-
-- `{evt}`: Matches any defined **Event** alias (e.g., `xmas`, `nye`).
-- `{per}`: Matches any defined **Period** alias (e.g., `midnight`, `noon`).
-- `{dt}`: Matches a date (e.g., `{dd}{sep}{mm}`) OR an event alias `{evt}`.
-- `{tm}`: Matches a time (e.g., `{hh}{mi}`) OR a period alias `{per}`.
-
-> [!IMPORTANT]
-> **Component-Aware Resolution**: To ensure that custom aliases don't accidentally overwrite explicit date or time parts in your input, `#parseGroups` is type-aware:
-> - **Events**: If an Event function returns a `Tempo` or `Temporal` object, it is converted to a `PlainDate` string (date-only) before parsing.
-> - **Periods**: If a Period function returns a `Tempo` or `Temporal` object, it is converted to a `PlainTime` string (time-only) before parsing.
->
-> This ensures that an Event result (like `tomorrow`) only modifies the date-component, and a Period result (like `morning`) only modifies the time-component, preserving any other explicitly provided fields.
-
-## Built-in Layouts
-
-Snippets are wrapped in curly braces `{}` and can be combined to create a layout.
-
-| Key | Layout | Description |
-| :--- | :--- | :--- |
-| `dt` | `{dt}` | Calendar or event |
-| `tm` | `{tm}` | Clock or period |
-| `wkd` | `'{mod}?{wkd}{afx}?{sfx}?'` | Weekday name |
-| `dtm` | `({dt}){sfx}?{brk}?` | Calendar/event and clock/period |
-| `dmy` | `{www}?{dd}{sep}?{mm}({sep}{yy})?{sfx}?{brk}?` | Day-month(-year) |
-| `mdy` | `{www}?{mm}{sep}?{dd}({sep}{yy})?{sfx}?{brk}?` | Month-day(-year) |
-| `ymd` | `{www}?{yy}{sep}?{mm}({sep}{dd})?{sfx}?{brk}?` | Year-month(-day) |
-| `unt` | `{nbr}{sep}?{unt}{sep}?{afx}` | Relative duration |
-| `evt` | `{evt}` | Event only |
-| `per` | `{per}` | Period only |
-
-## Creating a Layout
-
-To create a layout, arrange the snippets in the order they appear in your input string.
-
-### Example: `YYYYMMDD`
-If you have a string like `20240520`, your layout could be:
-`{yy}{sep}?{mm}{sep}?{dd}`
-
-### Example: `MMM-DD-YYYY`
-For a string like `May-20-2024`, your layout could be:
-`{mm}{sep}?{dd}{sep}?{yy}` or `{dd}{sep}?{mm}{sep}?{yy}`
-Either layout will match the string, as Tempo is timeZone-aware and will attempt to use whichever pattern returns a result.
-
-## Using Custom Layouts
-
-You can register custom layouts globally or use them just for a specific instance.
-
-### Global Registration
-
-Use `Tempo.init()` to add layouts that should be available to all new instances.
-> [!NOTE]
-> Assigning a 'name' to a Layout is optional and is auto-generated if not provided.
-> It is used internally-only to identify the layout when parsing a string.
-
-```typescript
-Tempo.init({
- layout: {
- 'myCustomFormat': '{dd}{sep}?{mm}{sep}?{yy}'
- }
-});
-
-const t = new Tempo('20-05-2024'); // Parsed using 'myCustomFormat'
-```
-
-### Instance-Specific Layout
-
-Pass a layout directly to the `Tempo` constructor.
-
-```typescript
-// Using a string
-const t1 = new Tempo('20240520', { layout: '{yy}{mm}{dd}' });
-
-// Using an array for a multiple layouts to match against a dateTime string
-const t2 = new Tempo('Monday, 20 May 2024', {
- layout: ['{wkd}{sep}?{dd}{sep}?{mm}{sep}?{yy}', '{dd}{sep}?{mm}{sep}?{yy}']
-})
-```
-
-## Advanced: Regex Layouts
-
-If the built-in snippets aren't enough, you can provide a raw Regular Expression or a mixture:
-
-```typescript
-const t = new Tempo('Year 2024 Day 20', {
- layout: 'Year {yy}, Day {dd}'
-})
-```
-
-To aid in designing a new Layout, use the static `Tempo.regexp()` method.
-It will return a Regular Expression that can be used to debug the layout against a string.
-
-```typescript
-let regex = Tempo.regexp('{yy}{sep}?{mm}{sep}?{dd}');
-let match = regex.exec('20240520')?.groups;
-// { yy: '2024', mm: '05', dd: '20' }
-```
-
-To aid in designing a new Snippet, use the static `Tempo.regexp()` method, with the snippet as the second argument.
-```typescript
-// first create Symbols for the snippet keys
-const innerSym = Tempo.getSymbol('inner_test');
-const outerSym = Tempo.getSymbol('outer_test');
-
-// create the snippet
-const snippet = {
- [innerSym]: /(?bar)/,
- [outerSym]: /(?foo{inner_test}baz)/,
-}
-
-// create the regex
-let regex = Tempo.regexp('{outer_test}', snippet);
-let match = regex.exec('foobarbaz')?.groups;
-// { outer: 'foobarbaz', inner: 'bar' }
-```
-
-> [!NOTE]
-> All layouts are automatically anchored with `^` and `$` and set to case-insensitive (`i`) when processed by the parsing engine.
diff --git a/packages/tempo/doc/tempo.pledge.md b/packages/tempo/doc/tempo.pledge.md
index 6b9627e8..09837956 100644
--- a/packages/tempo/doc/tempo.pledge.md
+++ b/packages/tempo/doc/tempo.pledge.md
@@ -7,7 +7,7 @@ The `Pledge` utility is a specialized wrapper around `Promise.withResolvers()` d
A `Pledge` provides direct access to its state and resolution methods.
```typescript
-import { Pledge } from '@magmacomputing/tempo';
+import { Pledge } from '@magmacomputing/tempo/library';
// 1. Instantiate
const p = new Pledge('DataFetch');
diff --git a/packages/tempo/doc/tempo.serializers.md b/packages/tempo/doc/tempo.serializers.md
index a116bc39..fd471b03 100644
--- a/packages/tempo/doc/tempo.serializers.md
+++ b/packages/tempo/doc/tempo.serializers.md
@@ -14,7 +14,7 @@ The serializers are heavily utilized under the hood but are also exposed for rob
Serializes variables, primitives, and rich objects into string-safe representations. Unlike standard JSON, it detects complex types and translates them into identifiable single key:value structures (e.g., `{"$BigInt":"123"}`).
```typescript
-import { stringify } from '@magmacomputing/tempo';
+import { stringify } from '@magmacomputing/tempo/library';
const richData = new Map([
['time', new Date()],
@@ -30,7 +30,7 @@ const safeString = stringify(richData);
The inverse of `stringify`. It rebuilds an object from its stringified representation, parsing the specialized `{ $Type: value }` signatures back into their native JavaScript object instances.
```typescript
-import { objectify } from '@magmacomputing/tempo';
+import { objectify } from '@magmacomputing/tempo/library';
const restored = objectify(safeString);
// restored instance is a true Map, containing true Date, Symbol, and BigInt primitives
@@ -40,7 +40,7 @@ const restored = objectify(safeString);
Creates a deep-copy of an object by piping it through `stringify` and immediately back through `objectify`. This results in a detached, deeply cloned object that retains its rich types.
```typescript
-import { cloneify } from '@magmacomputing/tempo';
+import { cloneify } from '@magmacomputing/tempo/library';
const detachedCopy = cloneify(richData);
```
diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md
index 8b57c228..5cbe394d 100644
--- a/packages/tempo/doc/tempo.term.md
+++ b/packages/tempo/doc/tempo.term.md
@@ -14,8 +14,6 @@ new Tempo('25-Dec-2024').term.season // ← computed on first access, cached
> [!TIP]
> **Transparent Discovery**: As of **v2.0.1**, all term properties are **enumerable**. Whilst modern `console.log` environments (like Node.js) will typically display these as `[Getter]` to preserve laziness, they *are* visible to property-scanning tools. This means a serializer (like `JSON.stringify`) or a deep-clone utility **will** trigger the eager evaluation of *every* registered term at once. To prevent terminal noise during these events (e.g., for invalid dates), initialize Tempo with **`silent: true`**.
----
-
## What a Term Does
A term plugin answers a single question:
@@ -31,7 +29,6 @@ Plugin expose two views of that result via the `Tempo.term` object:
| `tempo.term.` | A short identifier string (e.g. `'qtr'`, `'szn'`, `'zdc'`) |
| `tempo.term.` | The full matching range object, with all metadata fields (e.g. `key`, `day`, `month`, `year`, `sphere`, etc.) |
The `` and `` are defined by the plugin author, where the intent of the `` is to provide a short identifier value, and the intent of the `` is to provide the full matching range object.
----
## Provided Plugin
@@ -53,8 +50,6 @@ const t = new Tempo('15-Feb-2025', { sphere: 'south' });
t.term.qtr // → 'Q3' (southern hemisphere)
```
----
-
### `szn` / `season` — Meteorological Seasons
Maps the current date to the appropriate meteorological season.
@@ -77,8 +72,6 @@ const t = new Tempo('01-Jul-2025', { sphere: 'south' });
t.term.szn // → 'Winter' (southern hemisphere, different boundary dates)
```
----
-
### `zdc` / `zodiac` — Astrological Zodiac
Determines the Western astrological sign for the date.
@@ -95,8 +88,6 @@ t.term.zodiac.CN
// → { animal: 'Snake', traits: 'Wise, intuitive', element: 'Wood', yinYang: 'Yin' }
```
----
-
### `per` / `period` — Daily Time Periods
Classifies the time of day into a named period based on a pre-defined range.
@@ -119,8 +110,6 @@ t.term.per // → 'midday'
t.term.period // → { key: 'midday', hour: 12 }
```
----
-
## Inspecting Registered Terms
The static `Tempo.terms` getter returns a read-only list of all registered plugin:
@@ -135,8 +124,6 @@ Tempo.terms
// ]
```
----
-
## Activating Terms
In **Tempo Full**, all standard terms are enabled by default. In **Tempo Core**, you have three ways to opt-in:
@@ -165,8 +152,6 @@ import { QuarterTerm } from '@magmacomputing/tempo/term/quarter';
Tempo.extend(QuarterTerm);
```
----
-
## How to Define a Term Plugin
A term plugin is ideally created using the **`defineTerm`** factory function provided by the library. This ensures correct type-inference and automatically handles registration during the discovery phase.
@@ -219,7 +204,6 @@ export const MySeasonTerm = defineTerm({
});
```
-
### `Range` fields
A `Range` object must include a `key` and any subset of the date-time fields below.
@@ -283,8 +267,6 @@ const t = new Tempo('2025-02-15');
console.log(t.term.cfy); // → "FY2024" (because it's before July 2025)
```
----
-
## 🧭 Writing Math-Aware Terms
To unlock Tempo's advanced **Term Traversal** (e.g., `t.add({ '#quarter': 1 })`) and **Ticker Syncing**, a plugin must provide semantic **boundaries** (`start` and `end`).
@@ -313,8 +295,6 @@ return keyOnly ? 'MyTerm' : {
};
```
----
-
## 🕒 Terms in Tickers
Any term that provides `start` and `end` boundaries can be used to drive a `Tempo.ticker`. This is ideal for logic that doesn't follow a fixed duration (like seasons or fiscal quarters).
@@ -327,8 +307,6 @@ for await (const t of quarterly) {
}
```
----
-
## 🛠️ Developer Guide: Best Practices
To ensure a custom `Term` plugin integrates fully with Tempo, follow these guidelines:
@@ -339,8 +317,6 @@ To ensure a custom `Term` plugin integrates fully with Tempo, follow these guide
4. **Math Readiness**: Always use `getTermRange` or provide boundaries. Without them, users cannot use your term in `add()`, `set()`, or `ticker()`.
5. **Key consistency**: Ensure the `key` property you return in the `define` function's scope object matches the `key` definition of your plugin.
----
-
## 🧭 Best Practices: Idempotency & Side-Effects
> [!IMPORTANT]
diff --git a/packages/tempo/doc/tempo.ticker.md b/packages/tempo/doc/tempo.ticker.md
index 879bfaf3..bc5f2a5f 100644
--- a/packages/tempo/doc/tempo.ticker.md
+++ b/packages/tempo/doc/tempo.ticker.md
@@ -208,8 +208,6 @@ try {
> [!WARNING]
> If you are using `const` or `let` without a `finally` block, an assertion failure will skip the `stop()` call, leaving a live timer in the event loop. Always prefer the `using` keyword or `try...finally` for industrial-grade resource management.
----
-
### `Ticker` Object
The object returned by `Tempo.ticker()` (or an instance of the `Ticker` class) implements the following interface:
@@ -223,8 +221,6 @@ The object returned by `Tempo.ticker()` (or an instance of the `Ticker` class) i
| `[Symbol.asyncDispose]` | Standard async cleanup for `await using` blocks. |
| `[Symbol.asyncIterator]` | Standard async iteration support (for `for await` loops). |
----
-
## 📊 Reporting & Registry
The `Ticker` class maintains a static registry of all currently active tickers. This is useful for debugging, monitoring, or cleanup checks.
@@ -255,8 +251,6 @@ type Snapshot = {
}
```
----
-
## 🎯 One-Shot Ticker (Meeting Alerts)
You can use the ticker as a "one-shot" timer for specific events by simply specifying a **seed** value. This is perfect for setting up a single alert (e.g., for a meeting) that cleans itself up immediately after firing.
@@ -293,8 +287,6 @@ Tempo.ticker({
> [!WARNING]
> While `limit: 1` handles the stop condition automatically, always remember that if you are using long-running tickers without a limit, you **must** use the [Disposer Pattern](#zombie-tickers-warning) or manual `stop()` to avoid memory leaks and zombie processes.
----
-
## 🧭 Advanced: Syncing Multiple Clocks
If you need to show multiple timezones on a dashboard, avoid creating multiple tickers. Instead, use a single **Master Ticker** to drive all views. This prevents "drift" between the clocks and is much more efficient.
diff --git a/packages/tempo/doc/tempo.types.md b/packages/tempo/doc/tempo.types.md
index 2011d199..65bb608d 100644
--- a/packages/tempo/doc/tempo.types.md
+++ b/packages/tempo/doc/tempo.types.md
@@ -2,8 +2,6 @@
This document provides a reference for the core TypeScript types and interfaces used within the `Tempo` namespace. These types define the valid inputs, configuration options, and manipulation arguments for the library.
----
-
## `Tempo.DateTime`
The primary type used for arguments representing a point in time. `Tempo` is extremely flexible and can interpret a wide range of formats. It also provides methods to extract these back as `Temporal` objects (e.g., `toPlainDate()`, `toInstant()`, etc.).
@@ -19,8 +17,6 @@ type DateTime =
| undefined | null // Interpreted as "now"
```
----
-
## `Tempo.Options`
Configuration options that can be passed to `Tempo.init()` or the `Tempo` constructor.
@@ -41,8 +37,6 @@ interface Options {
}
```
----
-
## `Tempo.Add`
Used by the `.add()` method to specify a duration to add or subtract.
@@ -53,8 +47,6 @@ type Add = Partial>;
t.add({ days: 5, hours: -2 });
```
----
-
## `Tempo.Set`
Used by the `.set()` method to move to a specific unit boundary or date-time alias.
@@ -70,8 +62,6 @@ t.set({ event: 'xmas' }); // Relative or absolute event alias
t.set({ time: '14:30' }); // Specific time string
```
----
-
## `Tempo.Unit`
Valid date and time unit strings used throughout the API.
@@ -84,8 +74,6 @@ type Unit =
// ... etc.
```
----
-
## `Tempo.Until`
The argument passed to `.until()` and `.since()`.
@@ -99,8 +87,6 @@ t.until('2025-01-01', 'days');
t.since('yesterday', { timeZone: 'UTC' });
```
----
-
## `Tempo.Discovery`
The contract for global discovery via `Symbol.for($Tempo)`.
@@ -115,8 +101,6 @@ interface Discovery {
}
```
----
-
## `Tempo.TermPlugin`
The interface for defining custom business-logic plugins.
@@ -128,7 +112,6 @@ type TermPlugin = {
define: (this: Tempo, keyOnly?: boolean) => any;
}
```
----
## `Tempo.TickerOptions`
Advanced configuration for `Tempo.ticker()`. Extends `Temporal.DurationLike` (plural keys only).
diff --git a/packages/tempo/importmap.json b/packages/tempo/importmap.json
index 08274be7..6352c328 100644
--- a/packages/tempo/importmap.json
+++ b/packages/tempo/importmap.json
@@ -5,6 +5,8 @@
"@magmacomputing/tempo/ticker": "./dist/plugin/extend/extend.ticker.js",
"@magmacomputing/tempo/duration": "./dist/plugin/module/module.duration.js",
"@magmacomputing/tempo/format": "./dist/plugin/module/module.format.js",
+ "@magmacomputing/tempo/mutate": "./dist/plugin/module/module.mutate.js",
+ "@magmacomputing/tempo/parse": "./dist/plugin/module/module.parse.js",
"@magmacomputing/tempo/plugin": "./dist/plugin/plugin.index.js",
"@magmacomputing/tempo/enums": "./dist/tempo.enum.js",
"@magmacomputing/tempo/library": "./dist/library.index.js"
diff --git a/packages/tempo/index.md b/packages/tempo/index.md
index cfc42702..06131393 100644
--- a/packages/tempo/index.md
+++ b/packages/tempo/index.md
@@ -8,14 +8,38 @@ import { withBase } from 'vitepress'
const logoUrl = withBase('/logo.svg')
const getStartedUrl = withBase('/README')
+
+// --- Clock State ---
const hDeg = ref(0)
const mDeg = ref(0)
const sDeg = ref(0)
-const timeStr = ref('')
+const timeStr = ref('Loading...')
const tzStr = ref('')
+// --- Carousel State ---
+const activeIndex = ref(0)
+const isPaused = ref(false)
+const transitionEnabled = ref(true)
+
+const features = [
+ { title: 'Zero-Cost', details: 'Lazy evaluation and smart matching ensure instantiation overhead is near-zero.', icon: '⚡' },
+ { title: '#friday.last', details: 'Natural language parsing for business cycles. Resolve complex terms with zero configuration.', icon: '🎯' },
+ { title: 'Cycle Persistence', details: 'Shift by semantic terms while preserving your relative day-of-period offset.', icon: '🔄' },
+ { title: 'Tempo.ticker()', details: 'State-of-the-art timing engine with AsyncGenerator support and auto-adjusting TimeZones.', icon: '⏱️' },
+ { title: 'Temporal Inside', details: 'Built on the ECMAScript Temporal API. Inherit the reliability of the future standard.', icon: '🏗️' },
+ { title: 'Monorepo Resilient', details: 'Built for stability in complex environments with proxy-protected registries.', icon: '🛡️' },
+ { title: 'Tree-Shakable', details: 'Keep your bundle light. Only import the modules you need—from Fiscal calendars to Tickers.', icon: '📦' },
+ { title: 'Business Aware', details: 'Native support for fiscal quarters, years, and seasons. Perfect for financial applications.', icon: '📈' }
+]
+
+// 8 features + 3 clones for a seamless 3-card viewport
+const displayFeatures = [...features, ...features.slice(0, 3)]
+
let isMounted = false
let ticker = null
+let carouselTimer = null
+let fallbackIntervalId = null
+let initFailed = false
function updateHands(h24, m, s) {
const h = h24 % 12
@@ -24,38 +48,137 @@ function updateHands(h24, m, s) {
sDeg.value = (s / 60) * 360
}
-onMounted(async () => {
- isMounted = true
-
- // Dynamically import Tempo + TickerModule
- const [{ Tempo }, { TickerModule }] = await Promise.all([
- import('@magmacomputing/tempo'),
- import('@magmacomputing/tempo/ticker'),
- ])
-
- if (!isMounted) return
+// One-time library setup
+let initPromise = (async () => {
+ try {
+ // HMR Safeguard for development only (stripped in production)
+ let registry, originalHas
+ if (import.meta.env.DEV) {
+ const registryKey = Symbol.for('$LibrarySerializerRegistry')
+ registry = globalThis[registryKey] ??= new Map()
+ // HMR Workaround: Temporarily bypass registry presence checks to allow class re-hydration
+ originalHas = registry.has.bind(registry)
+ registry.has = () => false
+ }
+
+ const [{ Tempo }, { TickerModule }] = await Promise.all([
+ import('@magmacomputing/tempo'),
+ import('@magmacomputing/tempo/ticker'),
+ ])
+
+ if (import.meta.env.DEV) registry.has = originalHas
+
+ if (!Tempo.ticker) Tempo.extend(TickerModule)
+ Tempo.init()
+
+ return Tempo
+ } catch (e) {
+ console.error('Tempo failed to initialize:', e)
+ initFailed = true
+ initPromise = undefined
+ throw e
+ }
+})()
+
+async function startTicker() {
+ if (initFailed) return
+ try {
+ if (!initPromise) return
+ const Tempo = await initPromise
+ if (!isMounted) return
+
+ ticker?.stop()
+ if (fallbackIntervalId) clearInterval(fallbackIntervalId)
+
+ const sync = (t) => {
+ const dt = t.toDateTime()
+ updateHands(dt.hour, dt.minute, dt.second)
+ timeStr.value = t.format('{www}, {yyyy}-{mmm}-{dd} {hh}:{mi}:{ss}')
+ tzStr.value = t.tz
+ }
+
+ sync(new Tempo())
+ ticker = Tempo.ticker({ seconds: 1 }, sync)
+ } catch (e) {
+ timeStr.value = `Error: ${e.message || 'Unknown'}`
+ const fallback = () => {
+ const d = new Date()
+ updateHands(d.getHours(), d.getMinutes(), d.getSeconds())
+ timeStr.value = `Fallback: ${d.toLocaleTimeString()} (${e.message})`
+ tzStr.value = Intl.DateTimeFormat().resolvedOptions().timeZone
+ }
+ fallback()
+ if (fallbackIntervalId) clearInterval(fallbackIntervalId)
+ fallbackIntervalId = setInterval(fallback, 1000)
+ }
+}
- Tempo.extend(TickerModule)
+function startCarousel() {
+ if (carouselTimer) clearInterval(carouselTimer)
+ carouselTimer = setInterval(() => {
+ if (!isPaused.value) {
+ activeIndex.value++
+ // Seamless loop: if we hit the start of the clones, wait for slide then snap
+ if (activeIndex.value >= features.length) {
+ setTimeout(() => {
+ if (!isMounted) return
+ transitionEnabled.value = false
+ activeIndex.value = 0
+ setTimeout(() => { transitionEnabled.value = true }, 50)
+ }, 850)
+ }
+ }
+ }, 4000)
+}
- // Initial update
- const now = new Tempo()
- updateHands(now.hh, now.mi, now.ss)
- timeStr.value = now.format('{www}, {yyyy}-{mmm}-{dd} {hh}:{mi}:{ss}')
- tzStr.value = now.tz
+function handleVisibility() {
+ if (document.visibilityState === 'visible') {
+ startTicker()
+ startCarousel()
+ } else {
+ ticker?.stop()
+ if (fallbackIntervalId) clearInterval(fallbackIntervalId)
+ clearInterval(carouselTimer)
+ carouselTimer = null
+ }
+}
- // Continuous ticker
- ticker = Tempo.ticker({ seconds: 1 }, (t) => {
- updateHands(t.hh, t.mi, t.ss)
- timeStr.value = t.format('{www}, {yyyy}-{mmm}-{dd} {hh}:{mi}:{ss}')
- tzStr.value = t.tz
- })
+onMounted(() => {
+ isMounted = true
+ startTicker()
+ startCarousel()
+ document.addEventListener('visibilitychange', handleVisibility)
})
onUnmounted(() => {
isMounted = false
ticker?.stop()
- ticker = null
+ if (fallbackIntervalId) clearInterval(fallbackIntervalId)
+ clearInterval(carouselTimer)
+ document.removeEventListener('visibilitychange', handleVisibility)
})
+
+// --- A11y & Keyboard Controls ---
+const featureRefs = ref([])
+
+function handleKeydown(e) {
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault()
+ if (activeIndex.value > 0) activeIndex.value--
+ focusActiveCard()
+ } else if (e.key === 'ArrowRight') {
+ e.preventDefault()
+ if (activeIndex.value < features.length - 1) activeIndex.value++
+ focusActiveCard()
+ }
+}
+
+function focusActiveCard() {
+ setTimeout(() => {
+ const el = featureRefs.value[activeIndex.value]
+ if (el) el.focus()
+ }, 100)
+}
@@ -102,18 +225,39 @@ onUnmounted(() => {
-
-
-
Zero-Cost Constructor
-
Lazy evaluation and smart matching ensure instantiation overhead is near-zero, even with massive plugin lists.
-
-
-
Relational Math
-
Shift by semantic terms (Quarters, Seasons, Periods) while preserving your relative cycle offset.
+
+
+
+
-
-
Hardened & Modular
-
Built for resilience in complex monorepos with proxy-protected registries and decoupled diagnostics.
+
+
+
+
+
{{ feat.icon }}
+
{{ feat.title }}
+
{{ feat.details }}
+
+
@@ -273,38 +417,74 @@ onUnmounted(() => {
white-space: nowrap;
}
-.tempo-features {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
- gap: 24px;
- padding: 64px 24px;
+.tempo-carousel-container {
+ overflow: hidden;
+ padding: 60px 24px;
max-width: 1152px;
margin: 0 auto;
+ position: relative;
+}
+
+.tempo-carousel-controls {
+ position: absolute;
+ top: 24px;
+ right: 24px;
+ z-index: 10;
+}
+
+.tempo-carousel-toggle {
+ background: var(--vp-c-bg-soft);
+ border: 1px solid var(--vp-c-border);
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 1.2rem;
+ transition: all 0.2s;
+}
+
+.tempo-carousel-toggle:hover {
+ border-color: var(--vp-c-brand-1);
+ background: var(--vp-c-bg-mute);
+}
+
+.tempo-carousel-track {
+ display: flex;
+ width: 366.66%; /* (8 + 3) / 3 * 100% */
}
.tempo-feature-card {
- background-color: var(--vp-c-bg-soft);
+ flex: 0 0 calc(100% / 11);
+ padding: 12px;
+ box-sizing: border-box;
+}
+
+.tempo-feature-content {
padding: 24px;
+ height: 100%;
+ background-color: var(--vp-c-bg-soft);
border-radius: 12px;
border: 1px solid var(--vp-c-border);
- transition: border-color 0.25s, background-color 0.25s;
+ transition: all 0.3s ease;
+ cursor: default;
+ position: relative;
}
-.tempo-feature-card:hover {
+.tempo-feature-content:hover {
border-color: var(--vp-c-brand-1);
+ transform: translateY(-4px);
+ background-color: var(--vp-c-bg-mute);
}
-.tempo-feature-title {
- font-size: 1.25rem;
- font-weight: 700;
- margin-bottom: 8px;
- color: var(--vp-c-text-1);
-}
+.tempo-feature-icon { font-size: 2rem; margin-bottom: 12px; }
+.tempo-feature-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; color: var(--vp-c-brand-1); font-family: ui-monospace, monospace; }
+.tempo-feature-details { font-size: 0.9rem; line-height: 1.5; color: var(--vp-c-text-2); }
-.tempo-feature-details {
- font-size: 0.9rem;
- line-height: 1.6;
- color: var(--vp-c-text-2);
- margin: 0;
+@media (max-width: 768px) {
+ .tempo-carousel-track { width: 1100%; }
+ .tempo-feature-card { flex: 0 0 calc(100% / 11); }
}
diff --git a/packages/tempo/package.json b/packages/tempo/package.json
index 0b8142b4..9e91712f 100644
--- a/packages/tempo/package.json
+++ b/packages/tempo/package.json
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo",
- "version": "2.2.2",
+ "version": "2.2.3",
"description": "The Tempo core library",
"author": "Magma Computing Solutions",
"license": "MIT",
@@ -22,7 +22,14 @@
"parsing"
],
"type": "module",
- "sideEffects": false,
+ "sideEffects": [
+ "**/temporal.polyfill.js",
+ "**/*-polyfill.ts",
+ "**/module.*.js",
+ "**/module.*.ts",
+ "**/tempo.index.js",
+ "src/tempo.index.ts"
+ ],
"main": "dist/tempo.index.js",
"types": "dist/tempo.index.d.ts",
"imports": {
@@ -44,6 +51,10 @@
"development": "./src/plugin/module/module.format.ts",
"default": "./dist/plugin/module/module.format.js"
},
+ "#tempo/parse": {
+ "development": "./src/plugin/module/module.parse.ts",
+ "default": "./dist/plugin/module/module.parse.js"
+ },
"#tempo/mutate": {
"development": "./src/plugin/module/module.mutate.ts",
"default": "./dist/plugin/module/module.mutate.js"
@@ -134,6 +145,11 @@
"types": "./dist/plugin/module/module.mutate.d.ts",
"import": "./dist/plugin/module/module.mutate.js"
},
+ "./parse": {
+ "types": "./dist/plugin/module/module.parse.d.ts",
+ "development": "./src/plugin/module/module.parse.ts",
+ "default": "./dist/plugin/module/module.parse.js"
+ },
"./library": {
"types": "./dist/library.index.d.ts",
"import": "./dist/library.index.js"
@@ -146,13 +162,21 @@
"types": "./dist/plugin/term/term.index.d.ts",
"import": "./dist/plugin/term/term.index.js"
},
- "./bundle": "./dist/tempo.bundle.js"
+ "./bundle": {
+ "types": "./dist/tempo.index.d.ts",
+ "import": "./dist/tempo.bundle.esm.js"
+ },
+ "./global": {
+ "types": "./dist/tempo.index.d.ts",
+ "import": "./dist/tempo.bundle.js",
+ "script": "./dist/tempo.bundle.js"
+ }
},
"scripts": {
"test": "vitest run",
"test:dist": "cross-env TEST_DIST=true vitest run",
- "repl": "tsx --conditions=development -i --import ./bin/setup.polyfill.ts --import ./bin/repl.ts",
- "core": "tsx --conditions=development -i --import ./bin/setup.polyfill.ts --import ./bin/core.ts",
+ "repl": "tsx --conditions=development -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts",
+ "core": "cross-env TEMPO_LITE=true tsx --conditions=development -i --import ./bin/temporal-polyfill.ts --import ./bin/core.ts",
"build": "tsc -b && npm run build:bundle && npm run build:resolve",
"build:bundle": "rollup -c",
"build:resolve": "tsx bin/resolve-types.ts",
@@ -165,14 +189,17 @@
"docs:preview": "vitepress preview"
},
"files": [
- "dist/"
+ "dist/",
+ "README.md",
+ "CHANGELOG.md",
+ "img/"
],
"dependencies": {
"tslib": "^2.8.1"
},
"devDependencies": {
"@js-temporal/polyfill": "^0.5.1",
- "@magmacomputing/library": "2.2.2",
+ "@magmacomputing/library": "2.2.3",
"@rollup/plugin-alias": "^6.0.0",
"cross-env": "^7.0.3",
"magic-string": "^0.30.21",
diff --git a/packages/tempo/plan/standalone_parse.md b/packages/tempo/plan/standalone_parse.md
index 230be57e..6b4a0179 100644
--- a/packages/tempo/plan/standalone_parse.md
+++ b/packages/tempo/plan/standalone_parse.md
@@ -1,57 +1,58 @@
-# Roadmap: Standalone `parse()` Utility
+# Feasibility Analysis: Standalone `parse()` Function
-This document outlines the architectural requirements for extracting the Tempo parsing engine into a standalone, pure-function utility.
+This document outlines the architectural changes required to support a standalone `parse` function in a future release of Tempo, allowing users to leverage the "Smart Parser" without necessarily using the full `Tempo` class.
-## 🎯 Objective
-Enable users to perform complex date-time parsing without the overhead of the `Tempo` class wrapper.
-Syntax: `import { parse } from '@magmacomputing/tempo/parse';`
+## 1. Current State
+Currently, the `ParseModule` is designed as a **Plugin**.
+- **Context Dependent**: It relies on `this` being a `Tempo` instance to access internal state (`parseDepth`, `errored`, `isValid`) and configuration (`timeZone`, `locale`).
+- **Internal Coupling**: It uses `this[sym.$Internal]()` to retrieve the private state bucket.
+- **Return Type**: It returns a `Temporal.ZonedDateTime`, which the `Tempo` constructor then uses to hydrate the instance.
-## 🏗️ Current Challenges
-The parsing engine in `tempo.class.ts` is currently "statefully tied" to the instance via several internal mechanisms:
-
-### 1. The "Scratchpad" Problem
-The orchestrator (`#parse` / `#conform`) uses the parent `Tempo` instance as a temporary scratchpad during recursive resolution (e.g., resolving `{event}` or `{period}`).
-It "hijacks" `this.#anchor` and `this.#zdt` to prime the instance for the next recursive cycle.
-
-### 2. Match Accumulation
-Parsing results (matches) are buffered into a private instance property `#matches`. A standalone version needs a way to return these results or manage a temporary buffer.
-
----
-
-## 🛠️ Proposed Solution: Context Injection
-We must transition from **Implicit Instance State** to **Explicit Context Injection**.
-
-### 1. Define a `ParseContext`
+## 2. Proposed Standalone Signature
```typescript
-interface ParseContext {
- anchor: Temporal.ZonedDateTime;
- registry: Tempo.Internal.Registry;
- options: Tempo.Options;
- results: Tempo.Internal.Match[];
- depth: number;
- resolvingKeys: Set;
-}
-```
-
-### 2. Refactor Internal Pipeline
-The following methods must be converted from private methods to standalone functions that accept a `ParseContext`:
-- `#conform` -> `conform(input, ctx)`
-- `#parseLayout` -> `parseLayout(input, ctx)`
-- `#parseGroups` -> `parseGroups(groups, ctx)`
-
-### 3. Handle Recursive Lookups
-Instead of "hijacking" a `this` context, nested lookups will pass a **Cloned Context** with an updated anchor.
+import { parse } from '@magmacomputing/tempo/parse';
----
+// Standalone usage (returns a native Temporal.ZonedDateTime)
+const zdt = parse('20-May', {
+ timeZone: 'Africa/Cairo',
+ mode: 'strict'
+});
+```
-## 🚦 v2 Implementation Merit
+## 3. Implementation Feasibility
+
+### A. Stateless Lexing (High Feasibility)
+The core lexing logic (`module.lexer.ts`) is already highly modular. Functions like `parseWeekday`, `parseDate`, and `parseTime` are pure or near-pure. They could easily be adapted to take an explicit `options` object instead of relying on an instance config.
+
+### B. Refactoring the Engine (Medium Feasibility)
+To make `ParseEngine.parse` standalone, we would need to:
+1. **Lift State**: Instead of using `this[sym.$Internal]()`, the engine would accept a `State` object as an argument (or initialize a transient one).
+2. **Parameterize Config**: Pass `timezone`, `locale`, and `formats` as an explicit options object.
+3. **Functional Composition**: The logic in `module.composer.ts` (which assembles the final date) is already functional and would require minimal changes.
+
+### C. Challenges & Trade-offs
+1. **Circular Dependencies**: If the standalone `parse` function were to return a `Tempo` instance (convenience wrapper), it would create a circular dependency with `tempo.class.ts`. To avoid this, it should strictly return `Temporal.ZonedDateTime`.
+2. **Global Defaults**: A standalone function wouldn't automatically respect `Tempo.init()` defaults unless it explicitly looked them up from the global registry (e.g. `REGISTRY.defaults`).
+3. **Validation (The Guard)**: The standalone function MUST include the `Guard` validation logic to ensure that inputs are sanitized and valid before attempting lexing.
+4. **Forced Strict Mode**: For standalone parsing, it is recommended to force `mode: 'strict'`. This prevents the parser from "guessing" or returning partial dates when the input is ambiguous, ensuring deterministic results for native Temporal users.
+
+## 4. Proposed Architecture for Future Release
+
+```mermaid
+graph TD
+ A[Smart Lexer] --> B[Stateless Parse Engine]
+ B --> C[Standalone parse Function]
+ B --> D[Tempo Class parse]
+ C --> E[Temporal.ZonedDateTime]
+ D --> F[Tempo Instance]
+ G[Guard] --> B
+ H[Global Defaults] --> B
+```
-| Phase | Task | Complexity |
-| :--- | :--- | :--- |
-| **Phase 1** | Refactor `Tempo` class to use an internal `context` object for parsing instead of field-level properties. | Medium |
-| **Phase 2** | Extract `context`-based functions into `plugin/module/module.orchestrator.ts`. | Low |
-| **Phase 3** | Expose public `parse()` entry point and sub-path exports. | Low |
+## 5. Conclusion
+Implementing a standalone `parse` function is **Highly Feasible**.
+- Refactoring the `ParseEngine` to be stateless (accepting config/state as arguments) is the first step toward decoupling the parser.
+- A wrapper `parse()` function will then be exported from `#tempo/parse` to provide a dedicated standalone entry point.
+- Finally, `Tempo.class` will be updated to delegate its internal `#parse` logic to this shared stateless engine.
-## ⚠️ Known Risks
-- **Plugin Compatibility**: Plugins that rely on `this` inside their `define()` callback will need a shim or a breaking-change update to support the context object.
-- **Overhead**: Passing context objects through every call in the inner loop must be optimized to ensure we don't regress on parsing performance.
+This would allow Tempo to serve as a high-performance "Natural Language to Temporal" parser for users who prefer the native Temporal API but want the "Slick" parsing capabilities of Tempo.
diff --git a/packages/tempo/plan/ticker-reactive.md b/packages/tempo/plan/ticker-reactive.md
new file mode 100644
index 00000000..ab8a097d
--- /dev/null
+++ b/packages/tempo/plan/ticker-reactive.md
@@ -0,0 +1,55 @@
+# Plan: Reactive Ticker Enhancements
+
+## Overview
+This document outlines the requirements and design for making `Tempo.ticker` more dynamic and reactive to environment changes.
+
+## 1. Dynamic Updates
+### Requirements
+- Ability to update a running ticker's configuration without stopping and restarting.
+- Support for updating the following properties:
+ - **Interval**: Change the pulse frequency (e.g., from 1s to 1m).
+ - **Limit**: Extend or shorten the number of remaining ticks.
+ - **Until**: Adjust the stopping boundary.
+ - **TimeZone**: Shift the ticker's time perspective.
+ - **Seed**: Reset the "current" time of the ticker.
+
+### Proposed API
+```typescript
+const clock = Tempo.ticker({ seconds: 1 });
+
+// Later...
+clock.update({
+ seconds: 60, // slow down
+ timeZone: 'Europe/Paris' // shift perspective
+});
+```
+
+## 2. Environment Reactivity (The "Analog Clock" Scenario)
+### Problem
+An analog clock using `Tempo.ticker` on a mobile device or laptop may become "wrong" if the user travels across TimeZone boundaries. Currently, the ticker is pinned to the TimeZone detected at creation.
+
+### Discussion
+- **Detection**: How does the ticker know the TimeZone changed?
+ - Option A: Periodic check (e.g., every 60 seconds) of `Intl.DateTimeFormat().resolvedOptions().timeZone`.
+ - Option B: Check on every pulse (low overhead for `Intl` calls).
+- **Behavior**: If a change is detected:
+ - Should it automatically adopt the new TZ?
+ - Should it emit an event (e.g., `ticker.on('timezonechange', ...)`?
+- **"Discrepancy" Logic**:
+ - The user suggested checking for a "wide-enough" discrepancy in mutations.
+ - *Antigravity's Note*: Since the `epoch` is continuous, a TZ shift only affects the "wall clock" representation. A simpler check is just comparing the current system TZ name against the ticker's internal TZ name.
+
+### Use Case: Analog Clock
+For a clock, "Self-Adjustment" is a premium feature. If `autoTimeZone: true` is set, the ticker would:
+1. Verify the system timezone on each pulse.
+2. If it differs from the ticker's current timezone, it performs an internal `.set({ timeZone: newTz })`.
+3. The next `Tempo` instance emitted to the UI will have the correct hour hand position.
+
+## 3. Implementation Challenges
+- **Scheduling**: If the `interval` changes, the pending `setTimeout` must be cleared and recalculated.
+- **Immutability**: `Tempo` instances are immutable. The `TickerInstance` (manager) must handle the swapping of its internal `#current` reference safely.
+- **Performance**: Frequent `Intl` checks are generally fast, but we should ensure they don't impact 16ms (60fps) pulse loops if someone is using the ticker for animations.
+
+---
+*Date: 2026-04-19*
+*Status: Discussion / Requirement Gathering*
diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js
index a95af827..397d853b 100644
--- a/packages/tempo/rollup.config.js
+++ b/packages/tempo/rollup.config.js
@@ -44,19 +44,26 @@ const entryPoints = Object.fromEntries(
// Force inclusion of the full library for testing/distribution parity
// We resolve this relative to this config file's directory
-entryPoints['lib/common.index'] = path.resolve(__dirname, '../library/dist/common.index.js');
export default [
{
input: path.join(distPath, 'tempo.entry.js'),
- output: {
- file: 'dist/tempo.bundle.js',
- format: 'iife',
- name: 'Tempo',
- exports: 'default', // Ensures 'new Tempo()' works on the global object
- sourcemap: false,
- indent: '\t',
- },
+ output: [
+ {
+ file: 'dist/tempo.bundle.js',
+ format: 'iife',
+ name: 'Tempo',
+ exports: 'default', // Ensures 'new Tempo()' works on the global object
+ sourcemap: false,
+ indent: '\t',
+ },
+ {
+ file: 'dist/tempo.bundle.esm.js',
+ format: 'es',
+ sourcemap: false,
+ indent: '\t',
+ }
+ ],
plugins: [
resolve({ extensions: ['.js'] }),
indentFix()
diff --git a/packages/tempo/src/library.index.ts b/packages/tempo/src/library.index.ts
index 41414897..e369d3f9 100644
--- a/packages/tempo/src/library.index.ts
+++ b/packages/tempo/src/library.index.ts
@@ -7,3 +7,4 @@
export { Pledge } from '#library/pledge.class.js';
export { enumify, type Enum } from '#library/enumerate.library.js';
export { proxify } from '#library/proxy.library.js';
+export { stringify, objectify, cloneify } from '#library/serialize.library.js';
diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts
index 836dd663..0a389f20 100644
--- a/packages/tempo/src/plugin/extend/extend.ticker.ts
+++ b/packages/tempo/src/plugin/extend/extend.ticker.ts
@@ -5,8 +5,8 @@ import { instant, normaliseFractionalDurations } from '#library/temporal.library
import { markConfig } from '#library/symbol.library.js'
import { DURATIONS } from '../../tempo.enum.js'
-import sym from '../../tempo.symbol.js';
import { defineExtension } from '../plugin.util.js'
+import sym from '../../tempo.symbol.js';
import type { Tempo } from '../../tempo.class.js'
import type { TempoType } from '../plugin.type.js'
@@ -356,27 +356,32 @@ class TickerInstance implements Ticker.Descriptor {
export const TickerModule: Tempo.Extension = defineExtension({
name: 'TickerModule',
install(this: Tempo, TempoClass: TempoType) {
- (TempoClass as any).ticker = function (this: TempoType, arg1: any, arg2?: any): Ticker.Instance {
- const instance = new TickerInstance(this as unknown as TempoType, arg1, arg2);
- const proxy = new Proxy((() => instance.stop()) as any, {
- get: (_, prop) => {
- if (prop === 'pulse') return instance.pulse.bind(instance);
- if (prop === 'on') return instance.on.bind(instance);
- if (prop === 'stop') return instance.stop.bind(instance);
- if (prop === 'info') return instance.info;
- if (prop === 'next') return instance.next.bind(instance);
- if (prop === 'return') return instance.return.bind(instance);
- if (prop === 'throw') return instance.throw.bind(instance);
- if (prop === Symbol.asyncIterator) return () => proxy;
- if (prop === Symbol.asyncDispose) return instance[Symbol.asyncDispose].bind(instance);
- if (prop === Symbol.dispose) return instance[Symbol.dispose].bind(instance);
- return (instance as any)[prop];
- },
- apply: (target) => target()
- }) as unknown as Ticker.Instance;
-
- return instance.bootstrap(proxy);
- };
+ Object.defineProperty(TempoClass, 'ticker', {
+ value: function (this: TempoType, arg1: any, arg2?: any): Ticker.Instance {
+ const instance = new TickerInstance(this as unknown as TempoType, arg1, arg2);
+ const proxy = new Proxy((() => instance.stop()) as any, {
+ get: (_, prop) => {
+ if (prop === 'pulse') return instance.pulse.bind(instance);
+ if (prop === 'on') return instance.on.bind(instance);
+ if (prop === 'stop') return instance.stop.bind(instance);
+ if (prop === 'info') return instance.info;
+ if (prop === 'next') return instance.next.bind(instance);
+ if (prop === 'return') return instance.return.bind(instance);
+ if (prop === 'throw') return instance.throw.bind(instance);
+ if (prop === Symbol.asyncIterator) return () => proxy;
+ if (prop === Symbol.asyncDispose) return instance[Symbol.asyncDispose].bind(instance);
+ if (prop === Symbol.dispose) return instance[Symbol.dispose].bind(instance);
+ return (instance as any)[prop];
+ },
+ apply: (target) => target()
+ }) as unknown as Ticker.Instance;
+
+ return instance.bootstrap(proxy);
+ },
+ writable: true,
+ configurable: true,
+ enumerable: true
+ });
Object.defineProperty(TempoClass, 'tickers', {
get: () => Ticker.active,
diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts
index 3f1c3237..e89cf143 100644
--- a/packages/tempo/src/plugin/module/module.duration.ts
+++ b/packages/tempo/src/plugin/module/module.duration.ts
@@ -4,11 +4,17 @@ import { getAccessors } from '#library/reflection.library.js';
import { ifDefined } from '#library/object.library.js';
import { getRelativeTime } from '#library/international.library.js';
-import { defineInterpreterModule } from '../plugin.util.js';
+import { defineModule, interpret } from '../plugin.util.js';
import enums from '../../tempo.enum.js';
+import sym from '../../tempo.symbol.js';
import type { Tempo } from '../../tempo.class.js';
declare module '../../tempo.class.js' {
+ namespace Tempo {
+ /** returns a full Tempo Duration object (EDO) for the given input */
+ function duration(input: any): Tempo.Duration;
+ }
+
interface Tempo {
/** time duration until (returns Duration) */ until(dateTimeOrOpts?: Tempo.DateTime | Tempo.Options, opts?: Tempo.Options): Tempo.Duration;
/** time duration until (with unit, returns number) */ until(unit: Tempo.Unit, opts?: Tempo.Options): number;
@@ -143,5 +149,18 @@ duration.toDuration = (input: string | Temporal.DurationLikeObject) => {
/**
* Functional Module to attach duration methods to Tempo.
*/
-// @ts-ignore
-export const DurationModule: Tempo.Module = defineInterpreterModule('duration', duration);
+export const DurationModule: Tempo.Module = defineModule({
+ name: 'duration',
+ install(this: Tempo, TempoClass: typeof Tempo) {
+ // 1. Register logic in the global interpreter registry
+ const modules = (globalThis as any)[sym.$modules] ??= {};
+ if (isUndefined(modules['DurationModule'])) {
+ modules['DurationModule'] = duration;
+ }
+
+ // 2. Inject the static helper
+ (TempoClass as any).duration = function (this: typeof Tempo, input: any) {
+ return interpret(this, 'DurationModule', 'toDuration', false, input);
+ };
+ }
+});
diff --git a/packages/tempo/src/plugin/module/module.format.ts b/packages/tempo/src/plugin/module/module.format.ts
index 1e988b37..8ee7db09 100644
--- a/packages/tempo/src/plugin/module/module.format.ts
+++ b/packages/tempo/src/plugin/module/module.format.ts
@@ -80,4 +80,4 @@ function format(this: Tempo, fmt: any) {
}
// @ts-ignore
-export const FormatModule: Tempo.Module = defineInterpreterModule('format', format);
+export const FormatModule: Tempo.Module = defineInterpreterModule('FormatModule', format);
diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/plugin/module/module.mutate.ts
index cc4de8a0..a90a4f04 100644
--- a/packages/tempo/src/plugin/module/module.mutate.ts
+++ b/packages/tempo/src/plugin/module/module.mutate.ts
@@ -1,8 +1,8 @@
import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/type.library.js';
import { singular } from '#library/string.library.js';
-import sym from '../../tempo.symbol.js';
+import { sym } from '../../tempo.symbol.js';
import enums from '../../tempo.enum.js';
-import { REGISTRY } from '../../tempo.register.js';
+import { REGISTRY, _MODULES } from '../../tempo.register.js';
import { defineInterpreterModule, findTermPlugin, getHost } from '../plugin.util.js';
import { resolveTermMutation } from './module.term.js';
import type { Tempo } from '../../tempo.class.js';
@@ -14,11 +14,7 @@ import type * as t from '../../tempo.type.js';
function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options = {}) {
const state = (this as any)[sym.$Internal]();
if (!isZonedDateTime(state.zdt)) return this;
-
- const { zdt: selfZdt, parse: internalParse } = state;
- const TempoClass = getHost(this);
-
-
+ const { zdt: selfZdt } = state;
const overrides = {
timeZone: options.timeZone ?? this.tz,
calendar: options.calendar ?? this.cal,
@@ -42,7 +38,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
// 1. Shorthand String
if (isString(args) && args.startsWith('#')) {
const resolveType = type === 'add' ? 'add' : 'start';
- const res = resolveTermMutation(TempoClass, this, resolveType, args, (type === 'add' ? 1 : args), zdt);
+ const res = resolveTermMutation((this.constructor as any), this, resolveType, args, (type === 'add' ? 1 : args), zdt);
if (res === null) state.errored = true;
else zdt = res;
}
@@ -56,7 +52,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
// @ts-ignore - access to mutation guard
if (++state.mutateDepth > 100) {
// @ts-ignore - access to static logger
- TempoClass[sym.$logError](this.config, `Infinite recursion detected in mutation engine for key: ${key}, adjust: ${adjust}, depth: ${state.mutateDepth}`);
+ (this.constructor as any)[sym.$logError](this.config, `Infinite recursion detected in mutation engine for key: ${key}, adjust: ${adjust}, depth: ${state.mutateDepth}`);
state.errored = true;
return currZdt;
}
@@ -97,9 +93,18 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
const slug = `${op}.${single}`;
+ const parseInner = (input: any, anchor?: any) => {
+ const res = (this.constructor as any).from(input, { ...this.config, anchor });
+ if (res.isValid) {
+ state.matches.push(...res.parse.result);
+ return res.toDateTime();
+ }
+ return undefined;
+ };
+
// Term-based mutations
if (slug.endsWith('.term')) {
- const res = resolveTermMutation(TempoClass, this, op as any, term!, adjust, currZdt);
+ const res = resolveTermMutation((this.constructor as any), this, op as any, term!, adjust, currZdt);
if (res === null) state.errored = true;
return res ?? currZdt;
}
@@ -113,7 +118,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
case 'set.period': case 'set.time': case 'set.date': case 'set.event':
case 'set.dow': case 'set.wkd': {
- const res = internalParse(offset, currZdt, term);
+ const res = parseInner(offset, currZdt);
if (isUndefined(res)) state.errored = true;
return res ?? currZdt;
}
@@ -152,7 +157,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
default:
// @ts-ignore
- TempoClass[sym.$logError](this.config, `Unexpected method(${op}), unit(${key}) and offset(${adjust})`);
+ (this.constructor as any)[sym.$logError](this.config, `Unexpected method(${op}), unit(${key}) and offset(${adjust})`);
state.errored = true;
return currZdt;
}
@@ -163,18 +168,19 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
}, zdt);
}
else {
- // @ts-ignore - access to private constructor fallback
- return new TempoClass(args, { ...state.options, ...overrides, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth });
+ // 3. Return a new instance with the final state
+ // @ts-ignore - access to private constructor/state
+ return new (this.constructor as any)(args, { ...state.options, ...this.config, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth });
}
}
if (state.errored) {
// @ts-ignore - access to private constructor fallback
- return new TempoClass(null, { ...state.options, ...overrides, ...options, result: state.matches, [sym.$errored]: true, [sym.$mutateDepth]: state.mutateDepth });
+ return new (this.constructor as any)(null, { ...state.options, ...overrides, ...options, result: state.matches, [sym.$errored]: true, [sym.$mutateDepth]: state.mutateDepth });
}
// @ts-ignore
- return new TempoClass(zdt, { ...state.options, ...overrides, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth });
+ return new (this.constructor as any)(zdt, { ...state.options, ...overrides, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored, [sym.$mutateDepth]: state.mutateDepth });
} finally {
if (isRoot) state.matches = undefined;
@@ -183,9 +189,18 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options
}
/**
- * MutateModule registration
+ * Mutate Engine Implementation
*/
-// Eagerly register with the global registry to ensure availability even if .extend() is delayed
-REGISTRY.modules['MutateModule'] = mutate;
+const MutateEngine = {
+ add(this: Tempo, args?: any, options: t.Options = {}) {
+ return mutate.call(this, 'add', args, options);
+ },
+ set(this: Tempo, args?: any, options: t.Options = {}) {
+ return mutate.call(this, 'set', args, options);
+ }
+};
-export const MutateModule = defineInterpreterModule('MutateModule', mutate);
+/**
+ * MutateModule registration
+ */
+export const MutateModule = defineInterpreterModule('MutateModule', MutateEngine);
diff --git a/packages/tempo/src/plugin/module/module.parse.ts b/packages/tempo/src/plugin/module/module.parse.ts
new file mode 100644
index 00000000..3b76036d
--- /dev/null
+++ b/packages/tempo/src/plugin/module/module.parse.ts
@@ -0,0 +1,460 @@
+import '#library/temporal.polyfill.js';
+import { asType, isNull, isString, isObject, isZonedDateTime, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/type.library.js';
+import { asInteger, isNumeric } from '#library/coercion.library.js';
+import { instant } from '#library/temporal.library.js';
+import { ownKeys, ownEntries } from '#library/primitive.library.js';
+
+import type { Tempo } from '../../tempo.class.js';
+import { isTempo } from '../../tempo.symbol.js';
+import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './module.lexer.js';
+import { _MODULES } from '../../tempo.register.js';
+import { Match } from '../../tempo.default.js';
+import { resolveTermMutation, resolveTermValue } from './module.term.js';
+import { compose } from './module.composer.js';
+import { getRange, getTermRange, defineInterpreterModule } from '../plugin.util.js';
+import { sym } from '../../tempo.symbol.js';
+import * as t from '../../tempo.type.js';
+
+/**
+ * Internal Parse Engine Implementation
+ */
+const ParseEngine = {
+ /** parse DateTime input */
+ parse(this: any, tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime {
+ const state = this[sym.$Internal]();
+ if (isNull(tempo)) {
+ state.errored = true;
+ return undefined as any;
+ }
+
+ state.parseDepth = (state.parseDepth ?? 0) + 1;
+ const isRoot = state.parseDepth === 1;
+ if (isRoot) state.matches = [];
+ let today: Temporal.ZonedDateTime;
+
+ try {
+ const { config } = state;
+ const val = dateTime ?? state.anchor ?? (isTempo(tempo) ? tempo.toDateTime() : (isZonedDateTime(tempo) ? tempo : undefined));
+ const basis = isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone);
+
+ const tz = isTempo(basis) ? (basis as any).tz : (isZonedDateTime(basis) ? basis.timeZoneId : config.timeZone);
+ const cal = isTempo(basis) ? (basis as any).cal : (isZonedDateTime(basis) ? basis.calendarId : config.calendar);
+
+ today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal));
+
+ const TempoClass = this.constructor as typeof Tempo;
+
+ if (term) {
+ const ident = term.startsWith('#') ? term.slice(1) : term;
+ const termObj = (TempoClass as any)[sym.$terms].find((t: any) => t.key === ident || t.scope === ident);
+ if (!termObj) {
+ (TempoClass as any)[sym.$termError](state.config, term);
+ return undefined as any;
+ }
+
+ if (isNumeric(tempo as any)) {
+ const list = getRange(termObj, this, today);
+ const current = (getTermRange(this, list, false, today) as any);
+ if (!current) throw new RangeError(`Term index out of range: ${tempo} for ${term}`);
+
+ const isMultiCycle = isDefined(termObj.resolve) && list.some(r => r.year !== undefined);
+ const itemsPerCycle = isMultiCycle ? list.length / 3 : list.length;
+ const currentIdx = list.findIndex(r => r.key === current.key && (isMultiCycle ? r.year === current.year : true));
+
+ if (currentIdx === -1 || itemsPerCycle <= 0) throw new RangeError(`Term index out of range: ${tempo} for ${term}`);
+
+ const cycleOffset = Math.floor(currentIdx / itemsPerCycle) * itemsPerCycle;
+ const targetIdx = cycleOffset + (Number(tempo) - 1);
+ const item = list[targetIdx];
+
+ if (item) {
+ const range = (getTermRange(this, [item], false, today) as any);
+ if (range?.start) return range.start.toDateTime().withTimeZone(tz).withCalendar(cal);
+ }
+ throw new RangeError(`Term index out of range: ${tempo} for ${term}`);
+ }
+
+ if (tempo === term) {
+ const range = termObj.define.call(this, false, today);
+ const list = isUndefined(range) ? [] : (Array.isArray(range) ? range : [range]);
+ const current = (getTermRange(this, list, false, today) as any);
+ if (current?.start) return current.start.toDateTime().withTimeZone(tz).withCalendar(cal);
+ }
+ }
+
+ if (isString(tempo) && tempo.startsWith('#')) {
+ const res = resolveTermValue(TempoClass, this, tempo, today);
+ if (isZonedDateTime(res)) return res;
+ return undefined as any;
+ }
+
+ if (isUndefined(term) && isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#'))) {
+ const msg = `Unsupported Syntax: Term-based mutations (#) cannot be passed to the constructor. Use new Tempo().set(${JSON.stringify(tempo)}) instead.`;
+ (TempoClass as any)[sym.$logError](state.config, msg);
+ throw new Error(msg);
+ }
+
+ if (isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#')) && (TempoClass as any)[sym.$terms].length === 0) {
+ (TempoClass as any)[sym.$termError](state.config, Object.keys(tempo).find(k => k.startsWith('#'))!);
+ return undefined as any;
+ }
+
+ const isAnchored = isDefined(dateTime) || isDefined(state.anchor);
+ const resolvingKeys = new Set();
+ const res = ParseEngine.conform.call(this, tempo, today, isAnchored, resolvingKeys);
+
+ const { timeZone: tz2, calendar: cal2 } = state.config;
+ const targetTz = isString(tz2) ? tz2 : (tz2 as any).id ?? (tz2 as any).timeZoneId;
+ const targetCal = isString(cal2) ? cal2 : (cal2 as any).id ?? (cal2 as any).calendarId;
+
+ const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal);
+
+ dateTime = dt;
+ if (timeZone && state) state.config.timeZone = timeZone;
+
+ if (isZonedDateTime(dateTime) && !state.errored)
+ dateTime = dateTime.withTimeZone(targetTz).withCalendar(targetCal);
+
+ if (isRoot) {
+ if (Reflect.isExtensible(state.parse)) {
+ if (isUndefined(state.parse.result)) {
+ Object.defineProperty(state.parse, 'result', {
+ value: [...(state.matches ?? [])],
+ writable: true, configurable: true, enumerable: true
+ });
+ } else {
+ state.parse.result.push(...(state.matches ?? []));
+ }
+ }
+ }
+
+ return (isZonedDateTime(dateTime) && !state.errored) ? dateTime : undefined as any;
+ } finally {
+ if (isRoot) state.matches = undefined;
+ state.parseDepth--;
+ }
+ },
+
+ /** conform input to a Temporal.ZonedDateTime */
+ conform(this: any, tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): t.TypeValue {
+ const state = this[sym.$Internal]();
+ const arg = asType(tempo);
+ const { type, value } = arg;
+ const TempoClass = this.constructor as typeof Tempo;
+
+
+ if (!isZonedDateTime(dateTime)) {
+ (TempoClass as any)[sym.$logError](state.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`));
+ return arg;
+ }
+
+ let zdt = dateTime as any;
+
+ if (ParseEngine.isZonedDateTimeLike.call(this, tempo)) {
+ const { timeZone, calendar, value: _, ...options } = tempo as t.Options;
+
+ const keys = Object.keys(options);
+ if (keys.some(k => k.startsWith('#')) && (TempoClass as any)[sym.$terms].length === 0) {
+ (TempoClass as any)[sym.$termError](state.config, keys.find(k => k.startsWith('#'))!);
+ return undefined as any;
+ }
+
+ if (!isEmpty(options)) zdt = zdt.with(options as Temporal.ZonedDateTimeLikeObject);
+
+ if (timeZone)
+ if (isZonedDateTime(zdt)) zdt = zdt.withTimeZone(timeZone);
+ if (calendar)
+ zdt = zdt.withCalendar(calendar);
+
+ ParseEngine.result.call(this, { type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' });
+
+ return Object.assign(arg, {
+ type: 'Temporal.ZonedDateTime',
+ value: zdt,
+ })
+ }
+
+ if (isTempo(value)) {
+ const res = (value as any).toDateTime();
+ state.config.timeZone = res.timeZoneId;
+ state.config.calendar = res.calendarId;
+ return Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: res });
+ }
+
+ if (type !== 'String' && type !== 'Number' && type !== 'Function' && type !== 'AsyncFunction') {
+ ParseEngine.result.call(this, arg, { match: type });
+ return arg;
+ }
+
+ if (isString(value)) {
+ const trim = (value as string).trim();
+ const guard = (TempoClass as any)[sym.$guard].test(trim);
+
+ if (!guard) {
+ const keys = (obj: any) => {
+ const res = new Set();
+ let curr = obj;
+ while (curr && curr !== Object.prototype) {
+ ownKeys(curr).forEach(k => res.add(String(k)));
+ curr = Object.getPrototypeOf(curr);
+ }
+ return res;
+ };
+ const local = [...keys(state.parse.event), ...keys(state.parse.period)];
+ const bypass = local.some(key => trim.toLowerCase().includes(String(key).toLowerCase()));
+ if (!bypass) return arg;
+ }
+ }
+
+ return ParseEngine.parseLayout.call(this, value as string | number, dateTime, isAnchored, resolvingKeys);
+ },
+
+ /** match a string or number against known layouts */
+ parseLayout(this: any, value: string | number, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): t.TypeValue {
+ const state = this[sym.$Internal]();
+ const arg = asType(value);
+ const { type } = arg;
+ const trim = (type === 'String') ? (value as string).trim() : value.toString();
+ const resolving = new Set(resolvingKeys);
+ const TempoClass = this.constructor as typeof Tempo;
+
+ if (resolving.size >= 100) {
+ (TempoClass as any)[sym.$logError](state.config, new RangeError(`Infinite recursion detected in layout resolution for: ${String(value)}`));
+ return arg;
+ }
+
+ if (type === 'String') {
+ if (isEmpty(trim)) {
+ ParseEngine.result.call(this, arg, { match: 'Empty' });
+ return Object.assign(arg, { type: 'Empty' });
+ }
+ if (isIntegerLike(trim)) {
+ ParseEngine.result.call(this, arg, { match: 'BigInt' });
+ return Object.assign(arg, { type: 'BigInt', value: asInteger(trim) });
+ }
+ }
+ else {
+ if (Number.isNaN(value) || !Number.isFinite(value)) return arg;
+ if (trim.length <= 7) {
+ const msg = 'Cannot safely interpret number with less than 8-digits: use string instead';
+ (TempoClass as any)[sym.$logError](state.config, new TypeError(msg));
+ return arg;
+ }
+ }
+
+ if (!isZonedDateTime(dateTime)) return arg;
+
+ let zdt = dateTime as any;
+ const anchorTime = zdt.toPlainTime();
+ const map = state.parse.pattern;
+ for (const [symKey, pat] of map) {
+ const groups = ParseEngine.parseMatch.call(this, pat, trim);
+ if (isEmpty(groups)) continue;
+
+ const hasAlias = Object.keys(groups).some(k => k.includes('evt') || k.includes('per'));
+ const isRootMatch = Object.keys(groups).some(k => k === 'dt' || k === 'tm');
+ const hadEventOrPeriod = hasAlias || isRootMatch;
+
+ ParseEngine.result.call(this, arg, { match: symKey.description, groups: { ...groups } });
+
+ dateTime = parseZone(groups, dateTime, state.config);
+ dateTime = ParseEngine.parseGroups.call(this, groups, dateTime, isAnchored, resolvingKeys);
+
+ dateTime = parseWeekday(groups, dateTime, (TempoClass as any)[sym.$dbg], state.config);
+ dateTime = parseDate(groups, dateTime, (TempoClass as any)[sym.$dbg], state.config, state.parse["pivot"]);
+ dateTime = parseTime(groups, dateTime);
+
+ const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key))
+ || hadEventOrPeriod
+ || !dateTime.toPlainTime().equals(anchorTime);
+
+ if (!isAnchored && !hasTime)
+ dateTime = dateTime.withPlainTime('00:00:00');
+
+ if (isZonedDateTime(dateTime)) {
+ Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime, match: symKey.description, groups });
+ }
+
+ (TempoClass as any)[sym.$logDebug](state.config, 'groups', groups);
+ (TempoClass as any)[sym.$logDebug](state.config, 'pattern', symKey.description);
+
+ break;
+ }
+
+ return arg;
+ },
+
+ /** apply a regex-match against a value, and clean the result */
+ parseMatch(this: any, pat: RegExp, value: string | number) {
+ const groups = value.toString().match(pat)?.groups || {}
+
+ ownEntries(groups)
+ .forEach(([key, val]: [string, any]) => isEmpty(val) && delete groups[key]);
+
+
+ return groups as t.Groups;
+ },
+
+ /** resolve {event} | {period} to their date | time values (mutates groups) */
+ parseGroups(this: any, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime {
+ if (!isZonedDateTime(dateTime)) return dateTime;
+ const state = this[sym.$Internal]();
+ const TempoClass = this.constructor as typeof Tempo;
+
+ const prevAnchor = state.anchor;
+ const prevZdt = state.zdt;
+
+ state.anchor = dateTime;
+ state.zdt = dateTime;
+
+ state.parseDepth = (state.parseDepth ?? 0) + 1;
+ const isRoot = state.parseDepth === 1;
+ if (isRoot) state.matches = [];
+
+ try {
+ const resolved = new Set();
+ let pending: string[];
+
+ while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) {
+ const key = pending[0];
+
+ if (key === 'slk') {
+ const slk = groups[key];
+ const result = resolveTermMutation(TempoClass, this, 'set', slk, undefined, dateTime);
+ if (result === null) {
+ state.errored = true;
+ resolved.add(key);
+ delete groups[key];
+ break;
+ }
+ dateTime = result;
+ resolved.add(key);
+ delete groups[key];
+ continue;
+ }
+
+ const isEvent = Match.event.test(key);
+ const isGlobal = key.startsWith('g');
+ const isLocal = key.startsWith('l');
+ const idx = +key.substring((isGlobal || isLocal) ? 4 : 3);
+ const src = isGlobal ? (isEvent ? (TempoClass as any)[sym.$Internal]().parse.event : (TempoClass as any)[sym.$Internal]().parse.period) : (isEvent ? state.parse.event : state.parse.period);
+ const entry = ownEntries(src, true)[idx];
+
+
+ if (!entry) {
+ resolved.add(key);
+ continue;
+ }
+
+ const aliasKey = `${key}:${String(entry[0])}`;
+ if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) {
+ const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`;
+ state.errored = true;
+ (TempoClass as any)[sym.$logError](state.config, new RangeError(msg));
+ resolved.add(key);
+ continue;
+ }
+
+ resolvingKeys.add(aliasKey);
+ resolved.add(key);
+
+ const definition = entry[1];
+ let res: string = '';
+ if (typeof definition === 'function') {
+ try {
+ state.anchor = dateTime;
+ state.zdt = dateTime;
+ const result = (definition as Function).call(this);
+ if (isTempo(result)) dateTime = result.toDateTime();
+ else if (isZonedDateTime(result)) dateTime = result as Temporal.ZonedDateTime;
+ else dateTime = isZonedDateTime(state.zdt) ? (state.zdt as any) : dateTime;
+ res = String(result);
+ } catch (e: any) {
+ if (e.message.includes('Temporal')) {
+ res = (definition as any).toString();
+ } else {
+ throw e;
+ }
+ }
+ } else {
+ res = (definition as string);
+ }
+
+ if (isEvent && !isAnchored && isZonedDateTime(dateTime)) dateTime = (dateTime as any).startOfDay();
+
+ (TempoClass as any)[sym.$logDebug](state.config, 'event', `resolved "${key}" to "${res}" against ${(dateTime as any).toString?.() ?? String(dateTime)}`);
+
+ try {
+ const type = isEvent ? 'Event' : 'Period';
+ const val = entry![0];
+ const pat = (isEvent ? 'dt' : 'tm');
+ const resolveVal = typeof definition === 'function' ? res : definition;
+ ParseEngine.result.call(this, { type, value: val as any, match: pat, groups: { [key]: resolveVal as string } });
+
+ const resolving = new Set(resolvingKeys);
+ resolving.add(aliasKey);
+ const resMatch = ParseEngine.parseLayout.call(this, res, dateTime, isAnchored, resolving);
+
+ if (resMatch.type === 'Temporal.ZonedDateTime')
+ dateTime = resMatch.value;
+ } finally {
+ resolved.add(key);
+ }
+
+ delete groups[key];
+ }
+ } finally {
+ state.anchor = prevAnchor;
+ if (state.parseDepth === 1) {
+ state.zdt = prevZdt;
+ state.matches = undefined;
+ } else {
+ if (isZonedDateTime(dateTime)) state.zdt = dateTime;
+ }
+ state.parseDepth--;
+ }
+
+ if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) {
+ const mm = prefix(groups["mm"] as t.MONTH);
+ groups["mm"] = (TempoClass as any).MONTH[mm as t.MONTH]!.toString().padStart(2, "0");
+ }
+
+ return dateTime;
+ },
+
+ /** check if we've been given a ZonedDateTimeLike object */
+ isZonedDateTimeLike(this: any, tempo: t.DateTime | t.Options | undefined): tempo is Temporal.ZonedDateTimeLike & { value?: any } {
+ if (!isObject(tempo) || isEmpty(tempo))
+ return false;
+
+ const keys = ownKeys(tempo);
+ const TempoClass = this.constructor as typeof Tempo;
+ if (keys.some(key => (TempoClass as any)[sym.$Internal]().OPTION.has(key) && key !== 'value'))
+ return false;
+
+ return keys
+ .filter(isString)
+ .every((key: string) => (TempoClass as any)[sym.$Internal]().ZONED_DATE_TIME.has(key))
+ },
+
+ /** accumulate match results */
+ result(this: any, ...rest: Partial[]) {
+ const state = this[sym.$Internal]();
+ const match = Object.assign({}, ...rest) as t.Internal.Match;
+
+ if (isDefined(state.anchor) && !match.isAnchored)
+ match.isAnchored = true;
+
+ const res = state.matches ?? state.parse.result;
+ if (isDefined(res) && !Object.isFrozen(res)) {
+ if (!res.includes(match)) res.push(match);
+ }
+ }
+};
+
+/**
+ * # ParseModule
+ * The internal parsing engine for Tempo.
+ * Decouples date-string interpretation from the core class.
+ */
+export const ParseModule = defineInterpreterModule('ParseModule', ParseEngine);
diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts
index 7ed6335b..3b8df55f 100644
--- a/packages/tempo/src/plugin/plugin.util.ts
+++ b/packages/tempo/src/plugin/plugin.util.ts
@@ -1,45 +1,77 @@
import { toZonedDateTime, toInstant } from '#library/temporal.library.js';
-import { isDefined, isFunction, isString, isUndefined, isNumber, isClass } from '#library/type.library.js';
+import { isDefined, isFunction, isString, isUndefined, isNumber, isClass, isObject, isEmpty, isZonedDateTime } from '#library/type.library.js';
import { secure } from '#library/utility.library.js';
import { sortKey, byKey } from '#library/array.library.js';
import { secureRef } from '#library/proxy.library.js';
+import lib from '#library/symbol.library.js';
+import { REGISTRY, _INTERNAL_REGISTRY, _MODULES } from '../tempo.register.js';
import { SCHEMA, getLargestUnit } from '../tempo.util.js';
-import sym, { isTempo } from '../tempo.symbol.js';
+import { sym, isTempo } from '../tempo.symbol.js';
import type { Tempo } from '../tempo.class.js';
import type { TermPlugin, Range, ResolvedRange, Plugin } from './plugin.type.js';
-import { REGISTRY } from '../tempo.register.js';
-
export function getHost(t: any): any {
return isFunction(t) || isClass(t) ? t : (t as any).constructor;
}
+/**
+ * ## ensureModule
+ * Ensure a specific module is loaded, throwing a friendly error if not.
+ */
+export function ensureModule(t: any, module: string, silent: boolean = false): boolean {
+ const host = getHost(t);
+ const hostLogic = (REGISTRY.modules as any)[module];
+ const isTermsLoaded = (module === 'term' || module === 'TermsModule') && REGISTRY.terms.length > 0;
+
+ if (!isDefined(hostLogic) && !isTermsLoaded) {
+ const baseName = module.endsWith('Module') ? module.slice(0, -6) : module;
+ const msg = `Tempo: ${module} not loaded. (Did you forget to Tempo.extend(${module}) or import '#tempo/${baseName.toLowerCase()}'?)`;
+ if (!silent && isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg);
+
+ if (silent) return false;
+ if (t?.config?.catch === true) return false;
+ throw new Error(msg);
+ }
+ return true;
+}
/**
* ## interpret
* Utility to safely delegate calls to the Tempo Interpreter with catch-support.
*/
-export function interpret(t: any, module: string, methodOrFallback?: any, ...args: any[]) {
+export function interpret(t: any, module: string, methodOrFallback?: any, silent: boolean = false, ...args: any[]) {
const host = getHost(t);
- const hostLogic = REGISTRY.modules[module] ?? host[sym.$Interpreter]?.[module];
- try {
- if (!isFunction(hostLogic)) throw new Error(`${module} plugin not loaded`);
+ // 1. Module Validation
+ if (!ensureModule(t, module, silent)) {
+ if (isFunction(methodOrFallback)) return methodOrFallback.apply(t, args);
+ if ((isString(methodOrFallback) || isUndefined(methodOrFallback)) && (t?.config?.catch === true || silent)) return t;
+ return undefined;
+ }
+
+ const hostLogic = (REGISTRY.modules as any)[module];
- // Resolve the specific logic (either the module itself or a sub-method)
+ // 2. Resolve the specific logic (either the module itself or a sub-method)
const logic = isString(methodOrFallback) ? hostLogic[methodOrFallback] : hostLogic;
- if (!isFunction(logic)) throw new Error(`${module} ${methodOrFallback} method not loaded`);
- return logic.apply(t, args);
- } catch (err) {
- if (isFunction(host?.[sym.$logError])) {
- host[sym.$logError](t?.config, err);
- } else {
- console.error(`Tempo [${module}]: structural error - no logger available on host.`, err);
+ // 3. Logic Not Found or Not a Function
+ if (!isFunction(logic)) {
+ // Fallback to calling the function if provided
+ if (isFunction(methodOrFallback)) return methodOrFallback.apply(t, args);
+
+ // Special case: if hostLogic is an object and the first arg is a valid method name
+ if (isObject(hostLogic) && isString(args[0]) && isFunction((hostLogic as any)[args[0]])) {
+ const method = args.shift();
+ return (hostLogic as any)[method].apply(t, args);
+ }
+
+ const msg = `Tempo: ${module} method '${String(methodOrFallback)}' not found`;
+ if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg);
+ throw new Error(msg);
}
- }
- return (isFunction(methodOrFallback) ? methodOrFallback() : undefined);
+ // 4. Execute the logic
+ return logic.apply(t, args);
}
/**
@@ -68,11 +100,12 @@ export const defineInterpreterModule = (name: string, logic: any) =>
defineModule({
name,
install(this: Tempo, TempoClass: typeof Tempo) {
+ const modules = (_INTERNAL_REGISTRY.modules as any)[lib.$Target] ?? _MODULES;
// 1. Secure the Global Registry
- if (isDefined(REGISTRY.modules[name])) {
- if (REGISTRY.modules[name] !== logic) throw new Error(`Tempo Security: Core Module clash for '${name}'. Logic is already defined.`);
- } else {
- REGISTRY.modules[name] = logic;
+ if (isUndefined(modules[name])) {
+ modules[name] = logic;
+ } else if (modules[name] !== logic) {
+ throw new Error(`Tempo Security: Core Module clash for '${name}'. Logic is already defined.`);
}
// 2. Fallback for legacy class-local access
diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts
index cfbceee6..c7e02267 100644
--- a/packages/tempo/src/tempo.class.ts
+++ b/packages/tempo/src/tempo.class.ts
@@ -1,9 +1,8 @@
import '#library/temporal.polyfill.js';
-
import { Logify } from '#library/logify.class.js';
import { secure } from '#library/utility.library.js';
import { Immutable, Serializable } from '#library/class.library.js';
-import { asArray, asInteger, isNumeric } from '#library/coercion.library.js';
+import { asArray } from '#library/coercion.library.js';
import { getStorage, setStorage } from '#library/storage.library.js';
import { proxify, delegate } from '#library/proxy.library.js';
import lib, { markConfig } from '#library/symbol.library.js';
@@ -12,18 +11,15 @@ import { enumify } from '#library/enumerate.library.js';
import { ownKeys, ownEntries } from '#library/primitive.library.js';
import { getAccessors, omit } from '#library/reflection.library.js';
import { pad, trimAll } from '#library/string.library.js';
-import { getType, asType, isEmpty, isNull, isNullish, isDefined, isUndefined, isString, isObject, isNumber, isRegExp, isRegExpLike, isIntegerLike, isSymbol, isFunction, isClass, isZonedDateTime, isPlainDate, isPlainTime } from '#library/type.library.js';
+import { getType, asType, isEmpty, isNullish, isDefined, isUndefined, isString, isObject, isNumber, isRegExp, isRegExpLike, isIntegerLike, isSymbol, isFunction, isClass, isZonedDateTime, isPlainDate, isPlainTime } from '#library/type.library.js';
import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js';
import { instant } from '#library/temporal.library.js';
-import type { Property, TypeValue, Secure } from '#library/type.library.js';
+import type { Property, Secure } from '#library/type.library.js';
-import { compose } from './plugin/module/module.composer.js';
-import { resolveTermMutation, resolveTermValue } from './plugin/module/module.term.js';
-import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './plugin/module/module.lexer.js';
-import { REGISTRY, registryUpdate, registryReset, onRegistryReset } from './tempo.register.js';
-import { registerPlugin, registerTerm, getRange, getTermRange, interpret } from './plugin/plugin.util.js'
+import { registerPlugin, registerTerm, getTermRange, interpret, ensureModule } from './plugin/plugin.util.js'
import sym, { isTempo, registerHook } from './tempo.symbol.js';
+import { REGISTRY, registryUpdate, registryReset, onRegistryReset } from './tempo.register.js';
import { Match, Token, Snippet, Layout, Event, Period, Default, Guard } from './tempo.default.js';
import enums, { STATE, DISCOVERY } from './tempo.enum.js';
import * as t from './tempo.type.js'; // namespaced types (Tempo.*)
@@ -44,10 +40,6 @@ const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key);
const isLocal = (shape: { config: { scope: string } }) => shape.config.scope === 'local';
/** create an object based on a prototype */
const create = (obj: object, name: string): T => Object.create(proto(obj)[name]);
-/** helper to throw error if MutateModule is missing */
-const throwMutateModuleNotLoaded = () => {
- throw new Error('Tempo MutateModule not loaded. Did you forget to Tempo.extend(MutateModule)?');
-};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
namespace Internal {
export type State = t.Internal.State;
@@ -106,6 +98,11 @@ export class Tempo {
/** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true };
/** Set of allowed lowercased tokens for the Master Guard */ static #allowedTokens: Set = new Set();
+ /** @internal Static access to global private state. */
+ static [sym.$Internal]() {
+ return Tempo.#global;
+ }
+
/** @internal handle internal errors using the global config */
static [sym.$logError](...msg: any[]): void {
@@ -114,25 +111,13 @@ export class Tempo {
Tempo.#dbg.error(config, ...msg);
}
- /** @internal internal key for signaling pre-errored state in constructor */
- static [sym.$errored] = sym.$errored;
- /** @internal guard against infinite mutation recursion */
- static [sym.$mutateDepth] = 0;
- /** @internal hook to re-validate the Master Guard */
- static [sym.$rebuildGuard]() { Tempo.#buildGuard() }
-
- /** @internal handle internal debug info using the global config */
- static [sym.$logDebug](...msg: any[]): void {
- Tempo.#dbg.debug(...msg);
+ /** @internal handle internal debug logs */
+ static [sym.$logDebug](...args: any[]): void {
+ const config = (isObject(args[0]) && (args[0] as any)[lib.$Logify] === true) ? args.shift() : Tempo.#global.config;
+ markConfig(config);
+ Tempo.#dbg.debug(config, ...args);
}
- /** @internal Centralized error dispatcher for Term resolution failures */
- static [sym.$termError](config: t.Options, term: string): void {
- const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registered—did you forget to call Tempo.extend(TermsModule)?)" : "";
- const msg = `Unknown Term identifier: ${term}${hint}`;
- Tempo.#dbg.error(config, msg);
- if (config.catch !== true) throw new Error(msg);
- }
/**
* {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested.
@@ -246,7 +231,7 @@ export class Tempo {
const swap1 = (idx1 < idx2) && shape.parse.isMonthDay; // we prefer {mdy} and the 1st tuple was found earlier than the 2nd
const swap2 = (idx1 > idx2) && !shape.parse.isMonthDay; // we dont prefer {mdy} and the 1st tuple was found later than the 2nd
- if (swap1 || swap2) { // since {layouts} is an array, ok to swap by-reference
+ if (swap1 || swap2) { // since {layouts} is an array, ok to swap by-reference
[layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]];
chg = true;
}
@@ -256,22 +241,20 @@ export class Tempo {
shape.parse.layout = Object.fromEntries(layouts) as Layout; // rebuild Layout in new parse order
}
- // Modular parsing helpers moved to #tempo/plugin/module/lexer.ts
-
/** get first Canonical name of a supplied locale */
static #locale = (locale?: string) => {
let language: string | undefined;
- try { // lookup locale
+ try { // lookup locale
language = canonicalLocale(locale!);
- } catch (error) { } // catch unknown locale
+ } catch (error) { } // catch unknown locale
const global = Context.global;
return language ??
global?.navigator?.languages?.[0] ?? // fallback to current first navigator.languages[]
global?.navigator?.language ?? // else navigator.language
- Default.locale ?? // else default locale
+ Default.locale ?? // else default locale
locale // cannot determine locale
}
@@ -778,11 +761,6 @@ export class Tempo {
return this
}
- /** returns a full Tempo Duration object (EDO) for the given input */
- static duration(input: any): Tempo.Duration {
- return interpret(Tempo, 'duration', 'toDuration', input);
- }
-
/** @internal Reads options from persistent storage (e.g., localStorage). */
static readStore(key = Tempo.#global.config.store) {
return getStorage(key, {});
@@ -944,7 +922,8 @@ export class Tempo {
period: { ...parse.period },
mdyLocales: [...parse.mdyLocales],
mdyLayouts: [...parse.mdyLayouts],
- }) as Internal.Parse;
+ mode: parse.mode
+ });
}
/** iterate over Tempo properties */
@@ -984,7 +963,7 @@ export class Tempo {
/** instantiation Temporal Instant */ #now: Temporal.Instant;
/** underlying Temporal ZonedDateTime */ #zdt!: Temporal.ZonedDateTime;
/** indicator that the instance failed to parse */ #errored = false;
- /** temporary anchor used during parsing */ #anchor?: Temporal.ZonedDateTime | undefined;
+ /** temporary anchor used during parsing */ #anchor: Temporal.ZonedDateTime | undefined;
/** prebuilt formats, for convenience */ #fmt!: any;
/** mapping of terms to their resolved values */ #term!: any;
/** a collection of parse rule-matches */ #matches: Internal.Match[] | undefined;
@@ -995,24 +974,51 @@ export class Tempo {
/** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.Match[] } as Internal.Parse
} as Internal.State;
+
+
+ /** @internal internal key for signaling pre-errored state in constructor */
+ static [sym.$errored] = sym.$errored;
+ /** @internal guard against infinite mutation recursion */
+ static [sym.$mutateDepth] = 0;
+ /** @internal hook to re-validate the Master Guard */
+ static [sym.$rebuildGuard]() { Tempo.#buildGuard() }
+
+ /** @internal */ static [sym.$termError](config: Internal.Config, term: string): void {
+ const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registered—did you forget to call Tempo.extend(TermsModule)?)" : "";
+ const msg = `Unknown Term identifier: ${term}${hint}`;
+ Tempo.#dbg.error(config, msg);
+ if (config.catch !== true) throw new Error(msg);
+ }
+ /** @internal */ static get [sym.$terms](): t.TermPlugin[] { return REGISTRY.terms as t.TermPlugin[] }
+ /** @internal */ static get [sym.$dbg](): Logify { return Tempo.#dbg }
+ /** @internal */ static get [sym.$guard]() { return (Tempo as any).#guard }
+
/**
* @internal Internal access to instance private state.
* This surface is not part of the public contract and is subject to change.
*/
[sym.$Internal]() {
- const self = this;
+ const self = this as any;
return {
get zdt() { return self.#zdt },
+ set zdt(val: any) { self.#zdt = val },
get errored() { return self.#errored },
- set errored(val) { self.#errored = val },
+ set errored(val: any) { self.#errored = val },
get parseDepth() { return self.#parseDepth },
- set parseDepth(val) { self.#parseDepth = val },
+ set parseDepth(val: any) { self.#parseDepth = val },
get mutateDepth() { return self.#mutateDepth },
- set mutateDepth(val) { self.#mutateDepth = val },
+ set mutateDepth(val: any) { self.#mutateDepth = val },
get matches() { return self.#matches },
- set matches(val) { self.#matches = val },
+ set matches(val: any) { self.#matches = val },
+ get anchor() { return self.#anchor },
+ set anchor(val: any) { self.#anchor = val },
get options() { return self.#options },
- parse: (tempo: any, anchor: any, term?: any) => self.#parse(tempo, anchor, term)
+ get tempo() { return self.#tempo },
+ get now() { return self.#now },
+ config: self.#local.config,
+ parse: self.#local.parse,
+ OPTION: enums.OPTION,
+ ZONED_DATE_TIME: enums.ZONED_DATE_TIME
}
}
@@ -1085,7 +1091,7 @@ export class Tempo {
} catch (err) {
const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`;
if (this.#local.config.catch === true) {
- Tempo.#dbg.warn(this.#local.config, msg); // log as warning if in catch-mode
+ Tempo.#dbg.error(this.#local.config, msg); // log as error if in catch-mode
} else {
Tempo.#dbg.error(this.#local.config, err, msg); // log as error then re-throw
throw err;
@@ -1150,10 +1156,12 @@ export class Tempo {
// discovery phase
if (host === 'fmt') {
+ if (!ensureModule(this, 'FormatModule')) return undefined;
if (isDefined(this.#local.config.formats[key])) {
return this.#setLazy(target, key, () => this.format(key as t.Format))?.();
}
} else {
+ if (!ensureModule(this, 'TermsModule')) return undefined;
const term = Tempo.#termMap.get(key);
if (term) {
const isKeyOnly = term.key === key;
@@ -1292,8 +1300,8 @@ export class Tempo {
return this.#local.parse;
}
- /** Object containing results from all term plugins */ get term() { return this.#term }
- /** Formatted results for all pre-defined format codes */ get fmt() { return this.#fmt }
+ /** Keyed results for all resolved terms */ get term() { return this.#term }
+ /** Formatted results for all pre-defined format codes */ get fmt() { return this.#fmt }
/** units since epoch */ get epoch() {
return secure({
/** seconds since epoch */ ss: Math.trunc(this.toDateTime().epochMilliseconds / 1_000),
@@ -1313,26 +1321,18 @@ export class Tempo {
format(fmt: K) {
this.#ensureParsed();
- return interpret(this, 'format', () => `{${String(fmt)}}`, fmt);
- }
-
-
- /** time duration until another date-time */ until(...args: any[]): any {
- this.#ensureParsed();
- return interpret(this, 'duration', undefined, 'until', ...args);
+ return interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt);
}
- /** time elapsed since another date-time */ since(...args: any[]): any {
- this.#ensureParsed();
- return interpret(this, 'duration', undefined, 'since', ...args);
- }
+ /** time duration until another date-time */ until(arg0?: any, arg1?: any): any { this.#ensureParsed(); return interpret(this, 'DurationModule', undefined, false, 'until', arg0, arg1) ?? this; }
+ /** time elapsed since another date-time */ since(arg0?: any, arg1?: any): any { this.#ensureParsed(); return interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1) ?? this; }
- /** returns a new `Tempo` with specific duration added. */add(tempo?: t.Add, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', throwMutateModuleNotLoaded, 'add', tempo, options); }
- /** returns a new `Tempo` with specific offsets. */ set(tempo?: t.Set, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', throwMutateModuleNotLoaded, 'set', tempo, options); }
+ /** returns a new `Tempo` with specific duration added. */add(tempo?: t.Add, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', 'add', false, tempo, options) ?? this; }
+ /** returns a new `Tempo` with specific offsets. */ set(tempo?: t.Set, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', 'set', false, tempo, options) ?? this; }
/** returns a clone of the current `Tempo` instance. */ clone() { return new this.#Tempo(this, this.config) }
/** returns the underlying Temporal.ZonedDateTime */
- toDateTime() {
+ toDateTime(): Temporal.ZonedDateTime {
try {
this.#ensureParsed();
return isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC');
@@ -1346,7 +1346,7 @@ export class Tempo {
/** returns a Temporal.PlainDateTime representation */ toPlainDateTime() { return this.toDateTime().toPlainDateTime() }
/** returns the underlying Temporal.Instant */ toInstant() { return this.toDateTime().toInstant() }
- /** the current system time localized to this instance. */toNow() { return this.#Tempo.instant.toZonedDateTimeISO(this.tz).withCalendar(this.cal) }
+ /** the current system time localized to this instance. */toNow() { return instant().toZonedDateTimeISO(this.tz).withCalendar(this.cal) }
/** the date-time as a standard `Date` object. */ toDate() { return new Date(this.toDateTime().round({ smallestUnit: enums.ELEMENT.ms }).epochMilliseconds) }
/**ISO8601 string representation of the date-time. */
toString() {
@@ -1372,125 +1372,34 @@ export class Tempo {
/** parse DateTime input */
#parse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime {
- if (isNull(tempo)) { // fail-early
- this.#errored = true;
+ if (!ensureModule(this, 'ParseModule', true)) return this.#fallbackParse(tempo, dateTime, term);
+
+ const res = interpret(this, 'ParseModule', 'parse', false, tempo, dateTime, term);
+ if (isUndefined(res)) {
+ const msg = `Tempo: ParseModule error. Could not parse ${String(tempo)}`;
+ Tempo.#dbg.error(this.#local.config, msg);
+ if (this.#local.config.catch !== true) throw new Error(msg);
return undefined as any;
}
+ return res;
+ }
- this.#parseDepth++;
- const isRoot = this.#parseDepth === 1;
- if (isRoot) this.#matches = []; // initialize match accumulator
- let today: Temporal.ZonedDateTime;
-
- try {
- const { config } = this.#local;
- const val = dateTime ?? this.#anchor ?? (isTempo(tempo) ? tempo.toDateTime() : (isZonedDateTime(tempo) ? tempo : undefined));
- const basis = isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone);
-
- const tz = isTempo(basis) ? basis.tz : (isZonedDateTime(basis) ? basis.timeZoneId : config.timeZone);
- const cal = isTempo(basis) ? basis.cal : (isZonedDateTime(basis) ? basis.calendarId : config.calendar);
-
- today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal));
-
- if (term) {
- const ident = term.startsWith('#') ? term.slice(1) : term;
- const termObj = Tempo.#terms.find(t => t.key === ident || t.scope === ident);
- if (!termObj) {
- (Tempo as any)[sym.$termError](this.#local.config, term);
- return undefined as any;
- }
-
- // 1. if input is numeric, resolve by index
- if (isNumeric(tempo as any)) {
- const list = getRange(termObj, this, today);
- const current = (getTermRange(this, list, false, today) as any);
- const isMultiCycle = isDefined(termObj.resolve) && list.some(r => r.year !== undefined);
- const itemsPerCycle = isMultiCycle ? list.length / 3 : list.length;
- const currentIdx = list.findIndex(r => r.key === current.key && (isMultiCycle ? r.year === current.year : true));
-
- const cycleOffset = isMultiCycle ? Math.floor(currentIdx / itemsPerCycle) * itemsPerCycle : 0;
- const targetIdx = cycleOffset + (Number(tempo) - 1);
- const item = list[targetIdx];
-
- if (item) {
- const range = (getTermRange(this, [item], false, today) as any);
- if (range?.start) return range.start.toDateTime().withTimeZone(tz).withCalendar(cal);
- }
- throw new RangeError(`Term index out of range: ${tempo} for ${term}`);
- }
-
- // 2. if input is the term identifier itself, resolve current range
- if (tempo === term) {
- const range = termObj.define.call(this, false, today);
- const list = isUndefined(range) ? [] : asArray(range as unknown) as t.Range[];
- const current = (getTermRange(this, list, false, today) as any);
- if (current?.start) return current.start.toDateTime().withTimeZone(tz).withCalendar(cal);
- }
- }
-
+ #fallbackParse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime {
+ if (isZonedDateTime(tempo)) return tempo;
+ if (isTempo(tempo)) return tempo.toDateTime();
+ if (isString(tempo)) {
try {
- // anchor successfully determined
- } catch (err) {
- Tempo.#dbg.error(this.#local.config, err, 'Anchor determination failed');
- return this.toNow(); // fallback to absolute now
- }
-
- const isAnchored = isDefined(dateTime) || isDefined(this.#anchor);
- const resolvingKeys = new Set();
- const res = this.#conform(tempo, today, isAnchored, resolvingKeys);
-
- if (isString(tempo) && tempo.startsWith('#')) {
- const res = resolveTermValue(Tempo, this, tempo, today);
- if (isZonedDateTime(res)) return res;
- return undefined as any;
- }
-
-
- // security check: if it contains term-keys (#) in constructor mode, we should throw an unsupported syntax error
- if (isUndefined(term) && isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#'))) {
- const msg = `Unsupported Syntax: Term-based mutations (#) cannot be passed to the constructor. Use new Tempo().set(${JSON.stringify(tempo)}) instead.`;
- Tempo.#dbg.error(this.#local.config, msg);
- throw new Error(msg);
- }
-
- // security check: if it contains term-keys (#) while no plugins are loaded
- if (isObject(tempo) && Object.keys(tempo).some(k => k.startsWith('#')) && Tempo.#terms.length === 0) {
- (Tempo as any)[sym.$termError](this.#local.config, Object.keys(tempo).find(k => k.startsWith('#'))!);
- return undefined as any;
- }
-
-
- // re-fetch zone/cal as they may have been updated by brackets during #conform
- const { timeZone: tz2, calendar: cal2 } = this.#local.config;
- const targetTz = isString(tz2) ? tz2 : (tz2 as any).id ?? (tz2 as any).timeZoneId;
- const targetCal = isString(cal2) ? cal2 : (cal2 as any).id ?? (cal2 as any).calendarId;
-
- // results are now handled at the end of #parse via #matches
- const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal);
-
- dateTime = dt;
- if (timeZone && this.#local) this.#local.config.timeZone = timeZone;
-
- // Final adjustment to normalize timezone and calendar across all types
- if (isZonedDateTime(dateTime) && !this.#errored)
- dateTime = dateTime.withTimeZone(targetTz).withCalendar(targetCal);
-
- if (isRoot) {
- if (Reflect.isExtensible(this.#local.parse)) {
- // ensure 'result' array is present and append our discovered matches
- if (isUndefined(this.#local.parse.result)) {
- setProperty(this.#local.parse, 'result', [...(this.#matches ?? [])]);
- } else {
- this.#local.parse.result.push(...(this.#matches ?? []));
- }
- }
- }
-
- return (isZonedDateTime(dateTime) && !this.#errored) ? dateTime : undefined as any;
- } finally {
- if (isRoot) this.#matches = undefined;
- this.#parseDepth--;
+ const tz = (this.#local.config.timeZone as string) ?? 'UTC';
+ if ((tempo as string).includes('[')) return Temporal.ZonedDateTime.from(tempo as string);
+ return Temporal.PlainDateTime.from(tempo as string).toZonedDateTime(tz);
+ } catch { /* ignore and throw below */ }
}
+ if (isUndefined(tempo) || isEmpty(tempo)) return dateTime ?? instant().toZonedDateTimeISO(this.#local.config.timeZone);
+
+ const msg = 'Tempo ParseModule not loaded. Did you forget to Tempo.extend(ParseModule)?';
+ Tempo.#dbg.error(this.#local.config, msg);
+ if (this.#local.config.catch !== true) throw new Error(msg);
+ return undefined as any;
}
/** resolve constructor / method arguments */
@@ -1538,292 +1447,11 @@ export class Tempo {
if (isDefined(this.#anchor) && !match.isAnchored)
match.isAnchored = true;
- const res = this.#matches ?? this.#local.parse.result;
+ const res = (this.#matches ?? this.#local.parse.result) as any[];
if (isDefined(res) && !Object.isFrozen(res)) {
if (!res.includes(match)) res.push(match);
}
}
-
- /** conform input to a Temporal.ZonedDateTime */
- #conform(tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue {
- const arg = asType(tempo);
- const { type, value } = arg;
-
- if (!isZonedDateTime(dateTime)) {
- Tempo.#dbg.error(this.#local.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`));
- return arg;
- }
-
- let zdt = dateTime as any;
-
-
- if (this.#isZonedDateTimeLike(tempo)) { // tempo is ZonedDateTime-ish object (throw away 'value' property)
- const { timeZone, calendar, value: _, ...options } = tempo as t.Options;
-
- // security check: if it contains term-keys (#) in core mode, we should throw a hint
- const keys = Object.keys(options);
- if (keys.some(k => k.startsWith('#')) && Tempo.#terms.length === 0) {
- (Tempo as any)[sym.$termError](this.#local.config, keys.find(k => k.startsWith('#'))!);
- return undefined as any;
- }
-
- if (!isEmpty(options)) zdt = zdt.with(options as Temporal.ZonedDateTimeLikeObject);
-
- if (timeZone)
- if (isZonedDateTime(zdt)) zdt = zdt.withTimeZone(timeZone);// optionally set timeZone
- if (calendar)
- zdt = zdt.withCalendar(calendar); // optionally set calendar
-
- this.#result({ type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' });
-
- return Object.assign(arg, {
- type: 'Temporal.ZonedDateTime', // override {arg.type}
- value: zdt,
- })
- }
-
- if (type !== 'String' && type !== 'Number' && type !== 'Function' && type !== 'AsyncFunction') {
- this.#result(arg, { match: type }); // log the 'type' detected and return
- return arg;
- }
-
- if (isTempo(value)) {
- const res = (value as Tempo).toDateTime();
- this.#local.config.timeZone = res.timeZoneId;
- this.#local.config.calendar = res.calendarId;
- return Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: res });
- }
-
- if (isString(value)) {
- const trim = (value as string).trim();
- const guard = Tempo.#guard.test(trim);
-
- if (!guard) {
- const local = [...ownKeys(this.#local.parse.event), ...ownKeys(this.#local.parse.period)];
- const bypass = local.some(key => trim.toLowerCase().includes(String(key).toLowerCase()));
- if (!bypass) return arg;
- }
- }
-
- return this.#parseLayout(value as string | number, dateTime, isAnchored, resolvingKeys);
- }
-
- /** match a string or number against known layouts */
- #parseLayout(value: string | number, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue {
- const arg = asType(value);
- const { type } = arg;
- const trim = (type === 'String') ? (value as string).trim() : value.toString();
- const resolving = new Set(resolvingKeys);
-
- if (resolving.size >= 100) {
- Tempo.#dbg.error(this.#local.config, new RangeError(`Infinite recursion detected in layout resolution for: ${String(value)}`));
- return arg;
- }
-
- if (type === 'String') { // if original value is String
- if (isEmpty(trim)) { // don't conform empty string
- this.#result(arg, { match: 'Empty' });
- return Object.assign(arg, { type: 'Empty' });
- }
- if (isIntegerLike(trim)) { // if string representation of BigInt literal
- this.#result(arg, { match: 'BigInt' });
- return Object.assign(arg, { type: 'BigInt', value: asInteger(trim) });
- }
- }
- else { // else it is a Number
- if (Number.isNaN(value) || !Number.isFinite(value)) return arg; // ignore NaN/Infinity
- if (trim.length <= 7) { // cannot reliably interpret small numbers: might be {ss} or {yymmdd} or {dmmyyyy}
- const msg = 'Cannot safely interpret number with less than 8-digits: use string instead';
- Tempo.#dbg.error(this.#local.config, new TypeError(msg));
- return arg;
- }
- }
-
- if (!isZonedDateTime(dateTime)) return arg; // safety-check: cannot parse against a corrupted anchor
-
- let zdt = dateTime as any;
- const anchorTime = zdt.toPlainTime();
- const map = this.#local.parse.pattern;
- for (const [sym, pat] of map) {
- const groups = this.#parseMatch(pat, trim); // determine pattern-match groups
- if (isEmpty(groups)) continue; // no match, so skip this iteration
-
- const hasAlias = Object.keys(groups).some(k => k.includes('evt') || k.includes('per'));
- const isRootMatch = Object.keys(groups).some(k => k === 'dt' || k === 'tm');
- const hadEventOrPeriod = hasAlias || isRootMatch;
-
- this.#result(arg, { match: sym.description, groups: { ...groups } }); // stash the {key} of the pattern that was matched
-
- dateTime = parseZone(groups, dateTime, this.#local.config);
- dateTime = this.#parseGroups(groups, dateTime, isAnchored, resolvingKeys);
-
- /**
- * finished analyzing a matched pattern.
- * rebuild {arg.value} into a ZonedDateTime
- */
- dateTime = parseWeekday(groups, dateTime, Tempo.#dbg, this.#local.config);
- dateTime = parseDate(groups, dateTime, Tempo.#dbg, this.#local.config, this.#local.parse["pivot"]);
- dateTime = parseTime(groups, dateTime);
-
- // if no time-components were matched, strip time to midnight baseline
- const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key))
- || hadEventOrPeriod
- || !dateTime.toPlainTime().equals(anchorTime);
-
- if (!isAnchored && !hasTime)
- dateTime = dateTime.withPlainTime('00:00:00');
-
- if (isZonedDateTime(dateTime)) {
- Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime, match: sym.description, groups });
- }
-
- Tempo.#dbg.debug(this.#local.config, 'groups', groups); // show resolved date-time elements
- Tempo.#dbg.debug(this.#local.config, 'pattern', sym.description); // show the matched pattern
-
- break; // stop checking patterns
- }
-
- return arg;
- }
-
- /** apply a regex-match against a value, and clean the result */
- #parseMatch(pat: RegExp, value: string | number | (() => string)) {
- const groups = value.toString().match(pat)?.groups || {}
-
- ownEntries(groups) // remove undefined, NaN, null and empty values
- .forEach(([key, val]: [string, any]) => isEmpty(val) && delete groups[key]);
-
- return groups as Tempo.Groups;
- }
-
- /** resolve {event} | {period} to their date | time values (mutates groups) */
- #parseGroups(groups: Tempo.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime {
- if (!isZonedDateTime(dateTime)) return dateTime;
-
- const prevAnchor = this.#anchor;
- const prevZdt = this.#zdt;
-
- this.#anchor = dateTime; // temporarily anchor the instance so events resolve relative to current state
- this.#zdt = dateTime; // temporarily prime the instance to avoid recursion during event resolution
-
- this.#parseDepth++;
- const isRoot = this.#parseDepth === 1;
- if (isRoot) this.#matches = [];
-
- try {
- const resolved = new Set(); // track keys resolved in this pass
- let pending: string[];
-
- while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) {
- const key = pending[0];
-
- if (key === 'slk') {
- const slk = groups[key];
- // Resolve the shifter using the same engine as .set() / .add()
- const result = resolveTermMutation(Tempo, this, 'set', slk, undefined, dateTime);
- if (result === null) {
- // Immediately abort parsing by returning early to avoid non-ZonedDateTime errors
- this.#errored = true;
- resolved.add(key);
- delete groups[key];
- break;
- }
- dateTime = result;
- resolved.add(key);
- delete groups[key];
- continue;
- }
-
- const isEvent = Match.event.test(key);
- const isPeriod = Match.period.test(key);
- const isGlobal = key.startsWith('g');
- const isLocal = key.startsWith('l');
- const idx = +key.substring((isGlobal || isLocal) ? 4 : 3); // gevt0/lper0 (4) or evt0 (3)
- const src = isGlobal ? (isEvent ? Tempo.#global.parse.event : Tempo.#global.parse.period) : (isEvent ? this.#local.parse.event : this.#local.parse.period);
- const entry = ownEntries(src, true)[idx];
-
- if (!entry) {
- resolved.add(key);
- continue;
- }
-
- const aliasKey = `${key}:${entry[0]}`; // unique per-entry cycle guard key (e.g. gert0:tomorrow)
- if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) {
- const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`;
- this.#errored = true; // mark as errored for graceful fallback
- Tempo.#dbg.error(this.#local.config, new RangeError(msg));
- resolved.add(key);
- continue;
- }
-
- resolvingKeys.add(aliasKey);
- resolved.add(key);
-
- const definition = entry[1];
- let res: string = '';
- if (typeof definition === 'function') {
- try {
- this.#anchor = dateTime; // update anchor baseline for relative functional resolvers
- this.#zdt = dateTime; // sync instance state before call
- const result = (definition as Function).call(this);
- if (isTempo(result)) dateTime = result.toDateTime(); // capture shifted date from new instance
- else if (isZonedDateTime(result)) dateTime = result as Temporal.ZonedDateTime; // capture shifted date from Temporal object
- else dateTime = isZonedDateTime(this.#zdt) ? (this.#zdt as any) : dateTime; // capture any mutations back to loop
- res = String(result);
- } catch (e: any) {
- if (e.message.includes('Temporal')) { // handle cases where 'this' is used in a Temporal-strict way
- res = (definition as any).toString();
- } else {
- throw e;
- }
- }
- } else {
- res = (definition as string);
- }
-
- if (isEvent && !isAnchored && isZonedDateTime(dateTime)) dateTime = (dateTime as any).startOfDay();
-
- Tempo.#dbg.debug(this.#local.config, 'event', `resolved "${key}" to "${res}" against ${(dateTime as any).toString?.() ?? String(dateTime)}`);
-
- try {
- // restore Event/Period match reporting for this alias resolution pass
- const type = isEvent ? 'Event' : 'Period';
- const val = entry![0];
- const pat = (isEvent ? 'dt' : 'tm');
- const resolveVal = isFunction(definition) ? res : definition;
- this.#result({ type, value: val, match: pat, groups: { [key]: resolveVal as string } });
-
- const resolving = new Set(resolvingKeys);
- resolving.add(aliasKey);
- const resMatch = this.#parseLayout(res, dateTime, isAnchored, resolving);
-
- if (resMatch.type === 'Temporal.ZonedDateTime')
- dateTime = resMatch.value;
- } finally {
- resolved.add(key);
- }
-
- delete groups[key];
- }
- } finally {
- this.#anchor = prevAnchor; // restore anchor baseline
- if (this.#parseDepth === 1) {
- this.#zdt = prevZdt; // restore instance state only if root
- this.#matches = undefined;
- } else {
- if (isZonedDateTime(dateTime)) this.#zdt = dateTime; // only propagate valid Temporal state to parent level
- }
- this.#parseDepth--;
- }
-
- // resolve month-names into month-numbers (some browsers do not allow month-names when parsing a Date)
- if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) {
- const mm = prefix(groups["mm"] as t.MONTH); // conform month-name
- groups["mm"] = Tempo.MONTH[mm as t.MONTH]!.toString().padStart(2, "0");
- }
-
- return dateTime;
- }
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/packages/tempo/src/tempo.entry.ts b/packages/tempo/src/tempo.entry.ts
index 1b5387ea..ac308a63 100644
--- a/packages/tempo/src/tempo.entry.ts
+++ b/packages/tempo/src/tempo.entry.ts
@@ -1,7 +1,8 @@
-import { Tempo } from './tempo.index.js';
-
// Batteries Included: Register standard modules
// (This is already handled by tempo.index.js)
+import { Tempo } from './tempo.index.js';
+
+// NOTE: This file is referenced by Rollup during the build process to create the production-ready browser bundle.
// Attach directly to the window for the global bundle
if (typeof window !== 'undefined') {
diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts
index 50321913..9afa1d2a 100644
--- a/packages/tempo/src/tempo.index.ts
+++ b/packages/tempo/src/tempo.index.ts
@@ -1,13 +1,14 @@
import { Tempo } from './tempo.class.js';
import { onRegistryReset } from './tempo.register.js';
-import { TermsModule } from '#tempo/term';
+import { ParseModule } from '#tempo/parse';
+import { MutateModule } from '#tempo/mutate';
import { DurationModule } from '#tempo/duration';
import { FormatModule } from '#tempo/format';
-import { MutateModule } from '#tempo/mutate';
+import { TermsModule } from '#tempo/term';
// Batteries Included: Register standard modules
-const core = [MutateModule, FormatModule, DurationModule, TermsModule];
+const core = [ParseModule, MutateModule, FormatModule, DurationModule, TermsModule];
onRegistryReset(() => { Tempo.extend(core); });
diff --git a/packages/tempo/src/tempo.register.ts b/packages/tempo/src/tempo.register.ts
index 143f11ba..078703f1 100644
--- a/packages/tempo/src/tempo.register.ts
+++ b/packages/tempo/src/tempo.register.ts
@@ -84,7 +84,6 @@ export function registryReset() {
// Trigger all registered reset hooks
const hooks = resetHooks();
hooks.forEach(hook => hook());
- hooks.clear();
}
/** update a global registry with new discoverable data */
@@ -115,3 +114,5 @@ export function registryUpdate(name: keyof typeof STATE, data: Record items.some(r => isDefined(r[u])))?.[0];
}
/** helper to determine a safe forward step for infinite-loop recovery */
export function getSafeFallbackStep(range: Range | Range[], scope?: string): Temporal.DurationLike {
- const items = Array.isArray(range) ? range : [range];
+ const items = asArray(range);
const first = items[0] as any;
// prioritize stashed 'rollover' metadata (calculated by getTermRange) if available
diff --git a/packages/tempo/test/constructor.core.test.ts b/packages/tempo/test/constructor.core.test.ts
index 516659f6..e07f3f5a 100644
--- a/packages/tempo/test/constructor.core.test.ts
+++ b/packages/tempo/test/constructor.core.test.ts
@@ -1,5 +1,6 @@
import { Tempo } from '#tempo/core';
import { FormatModule } from '#tempo/format';
+import '#tempo/parse';
Tempo.extend(FormatModule);
@@ -21,8 +22,8 @@ describe('Tempo Core', () => {
});
it('should fail-fast (strict) if input fails Master Guard', () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
// 'Hello World' fails the guard, so it attempts immediate parsing and throws
expect(() => new Tempo('Hello World')).toThrow(/Cannot parse Date/);
});
@@ -30,18 +31,18 @@ describe('Tempo Core', () => {
describe("mode: 'strict'", () => {
it('should throw immediately on invalid TimeZone', () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
// Even with a valid-looking date, 'strict' forces immediate validation of all options
- expect(() => new Tempo('2024-01-01', { mode: Tempo.MODE.Strict, timeZone: 'Invalid/Zone' })).toThrow();
+ expect(() => new Tempo('2024-01-01', { mode: Tempo.MODE.Strict, timeZone: 'Invalid/Zone' })).toThrow(/Tempo: Unrecognized time zone Invalid\/Zone/);
});
});
describe("Global strategy overrides", () => {
it("should throw on invalid input when global mode is 'strict'", () => {
Tempo.init({ mode: Tempo.MODE.Strict });
- vi.spyOn(console, 'error').mockImplementation(() => {});
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
expect(() => new Tempo('Invalid Date')).toThrow();
});
});
@@ -54,22 +55,24 @@ describe('Tempo Core', () => {
expect(t.config.lazy).toBe(true);
// Throws only on access
- vi.spyOn(console, 'error').mockImplementation(() => {});
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
expect(() => t.yy).toThrow();
});
});
describe("catch: true (Advanced Error Handling)", () => {
it('should suppress immediate throws in strict mode', () => {
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
const t = new Tempo('2024-01-01', { mode: Tempo.MODE.Strict, timeZone: 'Invalid/Zone', catch: true });
expect(t.isValid).toBe(false);
expect(t.format('{yyyy}')).toBe('');
});
it('should suppress deferred throws in defer mode', () => {
- vi.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
const t = new Tempo('2024-01-01', { mode: Tempo.MODE.Defer, timeZone: 'Invalid/Zone', catch: true });
expect(t.isValid).toBe(false); // Validates on call
expect(t.format('{yyyy}')).toBe('');
diff --git a/packages/tempo/test/duration.core.test.ts b/packages/tempo/test/duration.core.test.ts
index 501e618a..8fbed18c 100644
--- a/packages/tempo/test/duration.core.test.ts
+++ b/packages/tempo/test/duration.core.test.ts
@@ -15,12 +15,12 @@ afterAll(() => {
});
describe('Tempo.duration() (Core)', () => {
+ beforeEach(() => { Tempo.init(); });
afterEach(() => vi.restoreAllMocks())
- it('should throw Error if plugin not loaded', () => {
- const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
- expect(() => Tempo.duration('P1Y')).toThrow('duration plugin not loaded');
- expect(spy).toHaveBeenCalled();
+ it('should be undefined if plugin not loaded', () => {
+ expect(Tempo.duration).toBeUndefined();
+ expect(() => (Tempo as any).duration('P1Y')).toThrow();
});
it('should work after manual extension', async () => {
diff --git a/packages/tempo/test/duration.lazy.test.ts b/packages/tempo/test/duration.lazy.test.ts
index 41811afb..4f3cc842 100644
--- a/packages/tempo/test/duration.lazy.test.ts
+++ b/packages/tempo/test/duration.lazy.test.ts
@@ -1,18 +1,19 @@
import { Tempo } from '#tempo/core';
-describe('Tempo Duration Plugin (Lazy)', () => {
+describe('Tempo.duration() (Core)', () => {
+ beforeEach(() => { Tempo.init(); });
afterEach(() => vi.restoreAllMocks())
it('should throw "plugin not loaded" by default', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
const t = new Tempo('2024-01-01');
- expect(() => t.until('2024-01-02')).toThrow('Tempo: duration plugin not loaded');
+ expect(() => t.until('2024-01-02')).toThrow(/Tempo: DurationModule module not loaded/);
expect(spy).toHaveBeenCalled();
});
it('should work after importing the plugin', async () => {
// @ts-ignore
- await import('../src/plugin/module/module.duration.js');
+ await import('#tempo/duration');
const t = new Tempo('2024-01-01');
const diff = t.until('2024-01-02', 'days');
diff --git a/packages/tempo/test/fiscal-cycle.core.test.ts b/packages/tempo/test/fiscal-cycle.core.test.ts
index bf3ecf47..203097a2 100644
--- a/packages/tempo/test/fiscal-cycle.core.test.ts
+++ b/packages/tempo/test/fiscal-cycle.core.test.ts
@@ -1,4 +1,5 @@
import { Tempo } from '#tempo/core';
+import '#tempo/parse';
import '#tempo/mutate';
import { FormatModule } from '#tempo/format';
import '#tempo/term/standard';
diff --git a/packages/tempo/test/instance.set.test.ts b/packages/tempo/test/instance.set.test.ts
index a748534b..2e1813a5 100644
--- a/packages/tempo/test/instance.set.test.ts
+++ b/packages/tempo/test/instance.set.test.ts
@@ -3,6 +3,13 @@ import { Tempo } from '#tempo';
const label = 'instance.set:';
describe(`${label} set method`, () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ test('throws on unknown #term', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => { });
+ const t = new Tempo();
+ expect(() => t.set({ '#unknown': 1 })).toThrow('Unknown Term identifier');
+ });
test('sets atomic units correctly', () => {
const t = new Tempo('2024-05-20');
diff --git a/packages/tempo/test/lazy.test.ts b/packages/tempo/test/lazy.test.ts
index c5bfaf26..e5e64b95 100644
--- a/packages/tempo/test/lazy.test.ts
+++ b/packages/tempo/test/lazy.test.ts
@@ -1,5 +1,5 @@
-import { Tempo } from '../src/tempo.class.js';
-import { FormatModule } from '../src/plugin/module/module.format.js';
+import { Tempo } from '#tempo';
+import { FormatModule } from '#tempo/format';
Tempo.extend(FormatModule);
diff --git a/packages/tempo/test/number-words.core.test.ts b/packages/tempo/test/number-words.core.test.ts
index b9f59130..cc4cd642 100644
--- a/packages/tempo/test/number-words.core.test.ts
+++ b/packages/tempo/test/number-words.core.test.ts
@@ -1,5 +1,6 @@
import { Tempo } from '#tempo/core';
import '#tempo/mutate';
+import '#tempo/parse';
describe('Number-Word Pilot (0-10)', () => {
it('should resolve word-based counts in weekday patterns', () => {
diff --git a/packages/tempo/test/proof.test.ts b/packages/tempo/test/proof.test.ts
index 7f0fc009..2229f59f 100644
--- a/packages/tempo/test/proof.test.ts
+++ b/packages/tempo/test/proof.test.ts
@@ -29,12 +29,12 @@ describe('Proof: Enumerable + Silent Mode', () => {
});
it('should still show errors when NOT in silent mode (baseline check)', () => {
- const spyW = vi.spyOn(console, 'warn').mockImplementation(() => { });
+ const spyE = vi.spyOn(console, 'error').mockImplementation(() => { });
const t = new Tempo('Invalid Date', { catch: true, silent: false });
// Trigger a failure (which calls Logify.catch with {catch: true})
try { t.term.quarter } catch (e) { }
- expect(spyW).toHaveBeenCalled();
+ expect(spyE).toHaveBeenCalled();
});
});
diff --git a/packages/tempo/test/tempo-import.test.ts b/packages/tempo/test/tempo-import.test.ts
index 6dfd858c..ef90d7c5 100644
--- a/packages/tempo/test/tempo-import.test.ts
+++ b/packages/tempo/test/tempo-import.test.ts
@@ -1,4 +1,4 @@
-import { Tempo } from '../src/tempo.class.js'
+import { Tempo } from '#tempo'
test('Tempo import', () => {
expect(Tempo).toBeDefined()
diff --git a/packages/tempo/test/term-dispatch.core.test.ts b/packages/tempo/test/term-dispatch.core.test.ts
index f495a1c8..bbb8b382 100644
--- a/packages/tempo/test/term-dispatch.core.test.ts
+++ b/packages/tempo/test/term-dispatch.core.test.ts
@@ -1,4 +1,5 @@
import { Tempo } from '#tempo/core';
+import '#tempo/parse';
import '#tempo/mutate';
import '#tempo/format';
import '#tempo/term/standard';
diff --git a/packages/tempo/test/term-shorthand.test.ts b/packages/tempo/test/term-shorthand.test.ts
index e237fe22..506a9e60 100644
--- a/packages/tempo/test/term-shorthand.test.ts
+++ b/packages/tempo/test/term-shorthand.test.ts
@@ -1,6 +1,7 @@
import { Tempo } from '#tempo'
describe('Tempo Term Literacy (Namespace Shorthand)', () => {
+ afterEach(() => vi.restoreAllMocks());
describe('.set() shorthand', () => {
test('set("#period.morning") sets to the start of morning', () => {
diff --git a/packages/tempo/test/term_unified.test.ts b/packages/tempo/test/term_unified.test.ts
index 12e5821e..78e1fbd4 100644
--- a/packages/tempo/test/term_unified.test.ts
+++ b/packages/tempo/test/term_unified.test.ts
@@ -5,8 +5,12 @@ describe('Term Unified Logic (Mutation & Identity)', () => {
const testDate = '2024-05-15T12:00:00+10:00[Australia/Sydney]';
beforeEach(() => {
- Tempo.init()
- })
+ Tempo.init();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
it('should jump to the start of a term using #term syntax in set()', () => {
const t = new Tempo(testDate, { catch: true, sphere: 'north' });
@@ -72,6 +76,7 @@ describe('Term Unified Logic (Mutation & Identity)', () => {
});
it('should throw an error for invalid terms when catch is false', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => { });
const t = new Tempo(testDate, { catch: false, sphere: 'north', silent: true });
expect(() => t.set({ start: '#invalid' })).toThrow(/Unknown Term identifier\: #invalid/);
});
diff --git a/packages/tempo/test/ticker.term.core.test.ts b/packages/tempo/test/ticker.term.core.test.ts
index 4d8098b2..b4eb5e0a 100644
--- a/packages/tempo/test/ticker.term.core.test.ts
+++ b/packages/tempo/test/ticker.term.core.test.ts
@@ -1,4 +1,5 @@
import { Tempo } from '#tempo/core';
+import '#tempo/parse';
import '#tempo/term/standard';
import { MutateModule } from '#tempo/mutate';
import { TickerModule } from '#tempo/ticker';
diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts
index e7521e2b..b0a18e60 100644
--- a/packages/tempo/vitest.config.ts
+++ b/packages/tempo/vitest.config.ts
@@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config';
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDist = process.env.TEST_DIST === 'true';
-const polyfill = resolve(__dirname, './bin/setup.polyfill.ts');
+const polyfill = resolve(__dirname, './bin/temporal-polyfill.ts');
export default defineConfig({
plugins: [],
@@ -24,10 +24,11 @@ export default defineConfig({
{ find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') },
{ find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') },
{ find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './dist/plugin/term/standard.index.js') },
- { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './dist/plugin/term/$1.js') },
- { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') },
{ find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/plugin/module/module.duration.js') },
{ find: /^#tempo\/format$/, replacement: resolve(__dirname, './dist/plugin/module/module.format.js') },
+ { find: /^#tempo\/parse$/, replacement: resolve(__dirname, './dist/plugin/module/module.parse.js') },
+ { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/plugin/module/module.mutate.js') },
+ { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') },
{ find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') },
{ find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') },
{ find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.$1.js') },
@@ -45,6 +46,8 @@ export default defineConfig({
{ find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') },
{ find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/plugin/module/module.duration.ts') },
{ find: /^#tempo\/format$/, replacement: resolve(__dirname, './src/plugin/module/module.format.ts') },
+ { find: /^#tempo\/parse$/, replacement: resolve(__dirname, './src/plugin/module/module.parse.ts') },
+ { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/plugin/module/module.mutate.ts') },
{ find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.ts') },
{ find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') },
{ find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/extend.$1.ts') },