From f725743f82604fd50dae1dc14c86b5c796b7102e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 09:55:06 +1000 Subject: [PATCH 1/9] parse v1 --- packages/tempo/.vitepress/config.ts | 34 +- packages/tempo/LICENSE | 21 + packages/tempo/bin/setup.polyfill.ts | 29 +- packages/tempo/bin/temporal-polyfill.ts | 10 + packages/tempo/doc/releases/index.md | 15 + packages/tempo/doc/releases/v0.x.md | 31 + packages/tempo/doc/releases/v1.x.md | 59 ++ .../doc/releases/{versions.md => v2.x.md} | 14 +- packages/tempo/doc/tempo.api.md | 26 + packages/tempo/doc/tempo.constructor.md | 32 - packages/tempo/doc/tempo.layout.md | 73 ++- packages/tempo/doc/tempo.pattern.md | 156 ----- packages/tempo/doc/tempo.pledge.md | 2 +- packages/tempo/package.json | 14 +- .../src/plugin/module/module.duration.ts | 23 +- .../tempo/src/plugin/module/module.mutate.ts | 56 +- .../tempo/src/plugin/module/module.parse.ts | 462 +++++++++++++++ packages/tempo/src/plugin/plugin.util.ts | 71 ++- packages/tempo/src/tempo.class.ts | 554 ++++-------------- packages/tempo/src/tempo.index.ts | 3 +- packages/tempo/src/tempo.register.ts | 3 +- packages/tempo/src/tempo.symbol.ts | 6 + packages/tempo/src/tempo.type.ts | 1 + packages/tempo/src/tempo.util.ts | 5 +- packages/tempo/test/constructor.core.test.ts | 24 +- packages/tempo/test/duration.core.test.ts | 8 +- packages/tempo/test/duration.lazy.test.ts | 5 +- packages/tempo/test/instance.set.test.ts | 7 + packages/tempo/test/proof.test.ts | 4 +- packages/tempo/test/term-shorthand.test.ts | 1 + packages/tempo/test/term_unified.test.ts | 9 +- 31 files changed, 1006 insertions(+), 752 deletions(-) create mode 100644 packages/tempo/LICENSE create mode 100644 packages/tempo/bin/temporal-polyfill.ts create mode 100644 packages/tempo/doc/releases/index.md create mode 100644 packages/tempo/doc/releases/v0.x.md create mode 100644 packages/tempo/doc/releases/v1.x.md rename packages/tempo/doc/releases/{versions.md => v2.x.md} (81%) delete mode 100644 packages/tempo/doc/tempo.constructor.md delete mode 100644 packages/tempo/doc/tempo.pattern.md create mode 100644 packages/tempo/src/plugin/module/module.parse.ts diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 52c2505a..34ab3d8b 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: 'Shorthand Engine', link: '/doc/tempo.shorthand' }, + { text: 'Weekday Engine', link: '/doc/tempo.weekday' }, + { text: 'Types System', link: '/doc/tempo.types' }, + { 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: 'Advanced Promises (Pledge)', link: '/doc/tempo.pledge' }, + { text: 'Decorators', link: '/doc/tempo.decorators' }, + { text: 'Enumerators', link: '/doc/tempo.enumerators' }, + { text: 'Serializers', link: '/doc/tempo.serializers' } + ] + }, { 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/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/bin/setup.polyfill.ts b/packages/tempo/bin/setup.polyfill.ts index 58d8dfab..2b412fd1 100644 --- a/packages/tempo/bin/setup.polyfill.ts +++ b/packages/tempo/bin/setup.polyfill.ts @@ -1,21 +1,16 @@ /** * 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'; +import './temporal-polyfill.js'; -if (typeof globalThis.Temporal === 'undefined') { - Object.defineProperty(globalThis, 'Temporal', { - value: Temporal, - enumerable: false, - configurable: true, - writable: true, - }); -} +// Bootstrap Core Modules for Tests +import { Tempo } from '../src/tempo.class.js'; +import { onRegistryReset } from '../src/tempo.register.js'; +import { ParseModule } from '../src/plugin/module/module.parse.js'; +import { MutateModule } from '../src/plugin/module/module.mutate.js'; + +const core = [ParseModule, MutateModule]; + +// Register core modules and ensure they are re-registered on every Tempo.init() +onRegistryReset(() => { Tempo.extend(core); }); +Tempo.extend(core); 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..63843910 --- /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..bfd166bc --- /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 81% rename from packages/tempo/doc/releases/versions.md rename to packages/tempo/doc/releases/v2.x.md index ec137411..972d4c07 100644 --- a/packages/tempo/doc/releases/versions.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,4 +1,4 @@ -# ๐Ÿ“œ Version History +# ๐Ÿ“œ Version 2.x History ## [v2.2.2] - 2026-04-18 ### ๐Ÿ›ก๏ธ Registry Stabilization @@ -19,10 +19,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.api.md b/packages/tempo/doc/tempo.api.md index ed42e15b..cc573359 100644 --- a/packages/tempo/doc/tempo.api.md +++ b/packages/tempo/doc/tempo.api.md @@ -9,6 +9,26 @@ 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. +- **`Function`**: A dynamic resolver (max depth 5). +- **`Temporal.*`**: Any native Temporal object (ZonedDateTime, PlainDate, etc.). + +--- + ## ๐Ÿ—๏ธ Static Methods ### `Tempo.init(options?: Tempo.Options)` @@ -178,3 +198,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.layout.md b/packages/tempo/doc/tempo.layout.md index 63b694cc..ba40a32b 100644 --- a/packages/tempo/doc/tempo.layout.md +++ b/packages/tempo/doc/tempo.layout.md @@ -2,14 +2,49 @@ 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. | + +### 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 +52,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.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/package.json b/packages/tempo/package.json index 1075e90a..278bbb1d 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -22,6 +22,7 @@ "parsing" ], "type": "module", + "sideEffects": false, "main": "dist/tempo.index.js", "types": "dist/tempo.index.d.ts", "imports": { @@ -43,6 +44,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" @@ -144,7 +149,8 @@ "./term": { "types": "./dist/plugin/term/term.index.d.ts", "import": "./dist/plugin/term/term.index.js" - } + }, + "./bundle": "./dist/tempo.bundle.js" }, "scripts": { "test": "vitest run", @@ -163,7 +169,9 @@ "docs:preview": "vitepress preview" }, "files": [ - "dist/" + "dist/", + "README.md", + "CHANGELOG.md" ], "dependencies": { "tslib": "^2.8.1" @@ -182,4 +190,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index 3f1c3237..a9d3a8e1 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,16 @@ 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['duration'])) { + modules['duration'] = duration; + } + + // 2. Inject the static helper + (TempoClass as any).duration = (input: any) => interpret(TempoClass, 'duration', 'toDuration', input); + } +}); diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/plugin/module/module.mutate.ts index cc4de8a0..224d05ea 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, module?: 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, term); 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, { ...this.config, ...options, result: state.matches, anchor: zdt, [sym.$errored]: state.errored }); } } 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; @@ -182,10 +188,22 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options } } +/** + * Mutate Engine Implementation + */ +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); + } +}; + /** * MutateModule registration */ -// Eagerly register with the global registry to ensure availability even if .extend() is delayed -REGISTRY.modules['MutateModule'] = mutate; +export const MutateModule = defineInterpreterModule('MutateModule', MutateEngine); -export const MutateModule = defineInterpreterModule('MutateModule', mutate); +// Eagerly register the engine with the global registry to ensure availability even if .extend() is delayed +_MODULES['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..fa58b627 --- /dev/null +++ b/packages/tempo/src/plugin/module/module.parse.ts @@ -0,0 +1,462 @@ +import '#library/temporal.polyfill.js'; +const Temporal = (globalThis as any).Temporal; +import { asType, isNull, isString, isObject, isNumber, 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 { trimAll } from '#library/string.library.js'; +import { secure } from '#library/utility.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 { REGISTRY, _MODULES } from '../../tempo.register.js'; +import { Match, Token } 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); + 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}`); + } + + 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); + } + } + + const isAnchored = isDefined(dateTime) || isDefined(state.anchor); + const resolvingKeys = new Set(); + const res = ParseEngine.conform.call(this, tempo, today, isAnchored, resolvingKeys); + + 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 { 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 (type !== 'String' && type !== 'Number' && type !== 'Function' && type !== 'AsyncFunction') { + ParseEngine.result.call(this, arg, { match: type }); + return arg; + } + + 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 (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 | (() => string)) { + 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); + +// Eagerly register the engine with the global registry to ensure availability even if .extend() is delayed +_MODULES['ParseModule'] = ParseEngine; diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 7ed6335b..72befa23 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,45 +1,73 @@ 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): 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 msg = `Tempo: ${module} module not loaded. (Did you forget to Tempo.extend() or import '#tempo/${module.toLowerCase()}'?)`; + if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); + + 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[]) { 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)) { + return isFunction(methodOrFallback) ? methodOrFallback.apply(t, args) : 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 +96,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..4f75bbf5 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,9 +1,10 @@ import '#library/temporal.polyfill.js'; +const Temporal = (globalThis as any).Temporal; 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 +13,17 @@ 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 './plugin/module/module.parse.js'; +import './plugin/module/module.mutate.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.*) @@ -106,6 +106,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 +119,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. @@ -778,11 +771,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 +932,8 @@ export class Tempo { period: { ...parse.period }, mdyLocales: [...parse.mdyLocales], mdyLayouts: [...parse.mdyLayouts], - }) as Internal.Parse; + mode: parse.mode + }); } /** iterate over Tempo properties */ @@ -982,37 +971,75 @@ export class Tempo { /** constructor tempo */ #tempo?: t.DateTime; /** constructor options */ #options = {} as t.Options; /** 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; - /** 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; - /** current parsing depth to manage state isolation */ #parseDepth = 0; - /** current mutation depth to manage infinite recursion */#mutateDepth = 0; + /** underlying Temporal ZonedDateTime */ #zdt_!: Temporal.ZonedDateTime; + /** indicator that the instance failed to parse */ #errored_ = false; + /** 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; + /** current parsing depth to manage state isolation */ #parseDepth_ = 0; + /** current mutation depth to manage infinite recursion */#mutateDepth_ = 0; /** instance values to complement static values */ #local = { /** instance configuration */ config: { [lib.$Logify]: true } as unknown as Internal.Config, /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.Match[] } as Internal.Parse } as Internal.State; + get #zdt(): Temporal.ZonedDateTime { return this.#zdt_ as Temporal.ZonedDateTime } + set #zdt(val: Temporal.ZonedDateTime) { this.#zdt_ = val } + get #errored(): boolean { return this.#errored_ } + set #errored(val: boolean) { this.#errored_ = val } + get #parseDepth(): number { return this.#parseDepth_ ?? 0 } + set #parseDepth(val: number) { this.#parseDepth_ = val } + get #mutateDepth(): number { return this.#mutateDepth_ ?? 0 } + set #mutateDepth(val: number) { this.#mutateDepth_ = val } + get #matches(): Internal.Match[] | undefined { return this.#matches_ } + set #matches(val: Internal.Match[] | undefined) { this.#matches_ = val } + get #anchor(): Temporal.ZonedDateTime | undefined { return this.#anchor_ } + set #anchor(val: Temporal.ZonedDateTime | undefined) { this.#anchor_ = val } + + /** @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 } } @@ -1027,7 +1054,7 @@ export class Tempo { /** iterate over instance formats */ [Symbol.iterator]() { - return ownEntries(this.#fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] + return ownEntries(this.#fmt_, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] } get [Symbol.toStringTag]() { // default string description @@ -1064,8 +1091,8 @@ export class Tempo { this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings } - this.#fmt = this.#setDelegator('fmt'); // initialize the format-delegator - this.#term = this.#setDelegator('term'); // initialize the term-delegator + this.#fmt_ = this.#setDelegator('fmt'); // initialize the format-delegator + this.#term_ = this.#setDelegator('term'); // initialize the term-delegator this.#anchor = this.#options.anchor; if ((this.#options as any)[sym.$errored]) this.#errored = true; @@ -1085,7 +1112,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 +1177,12 @@ export class Tempo { // discovery phase if (host === 'fmt') { + if (!ensureModule(this, 'format')) return undefined; if (isDefined(this.#local.config.formats[key])) { return this.#setLazy(target, key, () => this.format(key as t.Format))?.(); } } else { + if (!ensureModule(this, 'term')) return undefined; const term = Tempo.#termMap.get(key); if (term) { const isKeyOnly = term.key === key; @@ -1292,8 +1321,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), @@ -1327,12 +1356,12 @@ export class Tempo { return interpret(this, 'duration', undefined, 'since', ...args); } - /** 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', tempo, options); } + /** returns a new `Tempo` with specific offsets. */ set(tempo?: t.Set, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', 'set', tempo, options); } /** 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 +1375,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 +1401,25 @@ 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; - return undefined as any; - } - - 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); - } - } + return interpret(this, 'ParseModule', 'parse', tempo, dateTime, term) ?? this.#fallbackParse(tempo, dateTime, term); + } + #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 +1467,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.index.ts b/packages/tempo/src/tempo.index.ts index 50321913..4e7ad0d0 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -5,9 +5,10 @@ import { TermsModule } from '#tempo/term'; import { DurationModule } from '#tempo/duration'; import { FormatModule } from '#tempo/format'; import { MutateModule } from '#tempo/mutate'; +import { ParseModule } from './plugin/module/module.parse.js'; // 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..e750b54d 100644 --- a/packages/tempo/test/constructor.core.test.ts +++ b/packages/tempo/test/constructor.core.test.ts @@ -21,8 +21,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 +30,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 +54,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..19c2eeb6 100644 --- a/packages/tempo/test/duration.lazy.test.ts +++ b/packages/tempo/test/duration.lazy.test.ts @@ -1,12 +1,13 @@ 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: duration module not loaded/); expect(spy).toHaveBeenCalled(); }); 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/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/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/); }); From 2ddf5b2be0cba107d9fd9ed7ee11aa64253370a4 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 10:19:41 +1000 Subject: [PATCH 2/9] tidy --- packages/tempo/.vitepress/config.ts | 8 +++--- packages/tempo/bin/setup.polyfill.ts | 8 +++--- packages/tempo/doc/tempo-vs-temporal.md | 1 - packages/tempo/doc/tempo.api.md | 1 - packages/tempo/doc/tempo.enumerators.md | 30 +++++++++++++++-------- packages/tempo/doc/tempo.library.md | 1 + packages/tempo/doc/tempo.serializers.md | 6 ++--- packages/tempo/doc/tempo.term.md | 24 ------------------ packages/tempo/doc/tempo.ticker.md | 8 ------ packages/tempo/doc/tempo.types.md | 17 ------------- packages/tempo/test/duration.lazy.test.ts | 2 +- packages/tempo/test/lazy.test.ts | 4 +-- packages/tempo/test/tempo-import.test.ts | 2 +- packages/tempo/vitest.config.ts | 2 ++ 14 files changed, 38 insertions(+), 76 deletions(-) diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 34ab3d8b..646da620 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -44,9 +44,9 @@ export default defineConfig({ 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: 'Types System', link: '/doc/tempo.types' }, { text: 'Debugging', link: '/doc/tempo.debugging' } ] }, @@ -63,10 +63,10 @@ export default defineConfig({ text: 'Utility Library', items: [ { text: 'Library Overview', link: '/doc/tempo.library' }, - { text: 'Advanced Promises (Pledge)', link: '/doc/tempo.pledge' }, - { text: 'Decorators', link: '/doc/tempo.decorators' }, { text: 'Enumerators', link: '/doc/tempo.enumerators' }, - { text: 'Serializers', link: '/doc/tempo.serializers' } + { text: 'Serializers', link: '/doc/tempo.serializers' }, + { text: 'Decorators', link: '/doc/tempo.decorators' }, + { text: 'Advanced Promises (Pledge)', link: '/doc/tempo.pledge' }, ] }, { diff --git a/packages/tempo/bin/setup.polyfill.ts b/packages/tempo/bin/setup.polyfill.ts index 2b412fd1..557c159e 100644 --- a/packages/tempo/bin/setup.polyfill.ts +++ b/packages/tempo/bin/setup.polyfill.ts @@ -4,10 +4,10 @@ import './temporal-polyfill.js'; // Bootstrap Core Modules for Tests -import { Tempo } from '../src/tempo.class.js'; -import { onRegistryReset } from '../src/tempo.register.js'; -import { ParseModule } from '../src/plugin/module/module.parse.js'; -import { MutateModule } from '../src/plugin/module/module.mutate.js'; +import { Tempo } from '#tempo/core'; +import { onRegistryReset } from '#tempo/tempo.register.js'; +import { ParseModule } from '#tempo/parse'; +import { MutateModule } from '#tempo/mutate'; const core = [ParseModule, MutateModule]; 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 cc573359..cd8d8d08 100644 --- a/packages/tempo/doc/tempo.api.md +++ b/packages/tempo/doc/tempo.api.md @@ -59,7 +59,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`. 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.library.md b/packages/tempo/doc/tempo.library.md index 1a831afd..615d3360 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.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/test/duration.lazy.test.ts b/packages/tempo/test/duration.lazy.test.ts index 19c2eeb6..185c69de 100644 --- a/packages/tempo/test/duration.lazy.test.ts +++ b/packages/tempo/test/duration.lazy.test.ts @@ -13,7 +13,7 @@ describe('Tempo.duration() (Core)', () => { 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/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/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/vitest.config.ts b/packages/tempo/vitest.config.ts index e7521e2b..af70beaf 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -28,6 +28,8 @@ export default defineConfig({ { 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\/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') }, From a714a99259ede7a347ecfcfd9aa5f98eb5a3effb Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 11:07:04 +1000 Subject: [PATCH 3/9] realign --- packages/tempo/bin/setup.polyfill.ts | 16 ------------- packages/tempo/package.json | 14 ++++++++--- packages/tempo/rollup.config.js | 1 - .../src/plugin/module/module.duration.ts | 6 ++--- .../tempo/src/plugin/module/module.format.ts | 2 +- .../tempo/src/plugin/module/module.parse.ts | 9 +++---- packages/tempo/src/plugin/plugin.util.ts | 9 +++---- packages/tempo/src/tempo.class.ts | 24 ++++++++----------- packages/tempo/src/tempo.entry.ts | 5 ++-- packages/tempo/src/tempo.index.ts | 6 ++--- packages/tempo/test/constructor.core.test.ts | 1 + packages/tempo/test/duration.lazy.test.ts | 2 +- packages/tempo/test/fiscal-cycle.core.test.ts | 1 + packages/tempo/test/number-words.core.test.ts | 1 + .../tempo/test/term-dispatch.core.test.ts | 1 + packages/tempo/test/ticker.term.core.test.ts | 1 + packages/tempo/vitest.config.ts | 5 ++-- 17 files changed, 47 insertions(+), 57 deletions(-) delete mode 100644 packages/tempo/bin/setup.polyfill.ts diff --git a/packages/tempo/bin/setup.polyfill.ts b/packages/tempo/bin/setup.polyfill.ts deleted file mode 100644 index 557c159e..00000000 --- a/packages/tempo/bin/setup.polyfill.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Test/Dev Polyfill Bootstrap - */ -import './temporal-polyfill.js'; - -// Bootstrap Core Modules for Tests -import { Tempo } from '#tempo/core'; -import { onRegistryReset } from '#tempo/tempo.register.js'; -import { ParseModule } from '#tempo/parse'; -import { MutateModule } from '#tempo/mutate'; - -const core = [ParseModule, MutateModule]; - -// Register core modules and ensure they are re-registered on every Tempo.init() -onRegistryReset(() => { Tempo.extend(core); }); -Tempo.extend(core); diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 278bbb1d..f6d995c9 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -22,7 +22,15 @@ "parsing" ], "type": "module", - "sideEffects": false, + "sideEffects": [ + "**/temporal.polyfill.js", + "**/module.parse.js", + "**/module.mutate.js", + "**/tempo.index.js", + "**/*.polyfill.ts", + "**/*.module.ts", + "src/tempo.index.ts" + ], "main": "dist/tempo.index.js", "types": "dist/tempo.index.d.ts", "imports": { @@ -155,8 +163,8 @@ "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", diff --git a/packages/tempo/rollup.config.js b/packages/tempo/rollup.config.js index a95af827..151c10a2 100644 --- a/packages/tempo/rollup.config.js +++ b/packages/tempo/rollup.config.js @@ -44,7 +44,6 @@ 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 [ { diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index a9d3a8e1..73433489 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -154,11 +154,11 @@ export const DurationModule: Tempo.Module = defineModule({ install(this: Tempo, TempoClass: typeof Tempo) { // 1. Register logic in the global interpreter registry const modules = (globalThis as any)[sym.$modules] ?? {}; - if (isUndefined(modules['duration'])) { - modules['duration'] = duration; + if (isUndefined(modules['DurationModule'])) { + modules['DurationModule'] = duration; } // 2. Inject the static helper - (TempoClass as any).duration = (input: any) => interpret(TempoClass, 'duration', 'toDuration', input); + (TempoClass as any).duration = (input: any) => interpret(TempoClass, '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.parse.ts b/packages/tempo/src/plugin/module/module.parse.ts index fa58b627..96b632c3 100644 --- a/packages/tempo/src/plugin/module/module.parse.ts +++ b/packages/tempo/src/plugin/module/module.parse.ts @@ -1,17 +1,14 @@ import '#library/temporal.polyfill.js'; -const Temporal = (globalThis as any).Temporal; -import { asType, isNull, isString, isObject, isNumber, isZonedDateTime, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/type.library.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 { trimAll } from '#library/string.library.js'; -import { secure } from '#library/utility.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 { REGISTRY, _MODULES } from '../../tempo.register.js'; -import { Match, Token } from '../../tempo.default.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'; diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 72befa23..a5790f5e 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -19,15 +19,16 @@ export function getHost(t: any): any { * ## ensureModule * Ensure a specific module is loaded, throwing a friendly error if not. */ -export function ensureModule(t: any, module: string): boolean { +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 msg = `Tempo: ${module} module not loaded. (Did you forget to Tempo.extend() or import '#tempo/${module.toLowerCase()}'?)`; - if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); + 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); } @@ -37,11 +38,11 @@ export function ensureModule(t: any, module: string): boolean { * ## 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); // 1. Module Validation - if (!ensureModule(t, module)) { + if (!ensureModule(t, module, silent)) { return isFunction(methodOrFallback) ? methodOrFallback.apply(t, args) : undefined; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 4f75bbf5..5c5d4c53 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,6 +1,4 @@ import '#library/temporal.polyfill.js'; -const Temporal = (globalThis as any).Temporal; - import { Logify } from '#library/logify.class.js'; import { secure } from '#library/utility.library.js'; import { Immutable, Serializable } from '#library/class.library.js'; @@ -19,8 +17,6 @@ import { instant } from '#library/temporal.library.js'; import type { Property, Secure } from '#library/type.library.js'; import { registerPlugin, registerTerm, getTermRange, interpret, ensureModule } from './plugin/plugin.util.js' -import './plugin/module/module.parse.js'; -import './plugin/module/module.mutate.js'; import sym, { isTempo, registerHook } from './tempo.symbol.js'; import { REGISTRY, registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; @@ -1177,12 +1173,12 @@ export class Tempo { // discovery phase if (host === 'fmt') { - if (!ensureModule(this, 'format')) return undefined; + 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, 'term')) return undefined; + if (!ensureModule(this, 'TermsModule')) return undefined; const term = Tempo.#termMap.get(key); if (term) { const isKeyOnly = term.key === key; @@ -1342,22 +1338,22 @@ export class Tempo { format(fmt: K) { this.#ensureParsed(); - return interpret(this, 'format', () => `{${String(fmt)}}`, fmt); + return interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt); } - /** time duration until another date-time */ until(...args: any[]): any { + /** time duration until another date-time */ until(arg0?: any, arg1?: any): any { this.#ensureParsed(); - return interpret(this, 'duration', undefined, 'until', ...args); + return interpret(this, 'DurationModule', undefined, false, 'until', arg0, arg1); } - /** time elapsed since another date-time */ since(...args: any[]): any { + /** time elapsed since another date-time */ since(arg0?: any, arg1?: any): any { this.#ensureParsed(); - return interpret(this, 'duration', undefined, 'since', ...args); + return interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1); } - /** returns a new `Tempo` with specific duration added. */add(tempo?: t.Add, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', 'add', tempo, options); } - /** returns a new `Tempo` with specific offsets. */ set(tempo?: t.Set, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', '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); } + /** 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); } /** returns a clone of the current `Tempo` instance. */ clone() { return new this.#Tempo(this, this.config) } /** returns the underlying Temporal.ZonedDateTime */ @@ -1401,7 +1397,7 @@ export class Tempo { /** parse DateTime input */ #parse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { - return interpret(this, 'ParseModule', 'parse', tempo, dateTime, term) ?? this.#fallbackParse(tempo, dateTime, term); + return interpret(this, 'ParseModule', 'parse', true, tempo, dateTime, term) ?? this.#fallbackParse(tempo, dateTime, term); } #fallbackParse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { 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 4e7ad0d0..9afa1d2a 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -1,11 +1,11 @@ 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 { ParseModule } from './plugin/module/module.parse.js'; +import { TermsModule } from '#tempo/term'; // Batteries Included: Register standard modules const core = [ParseModule, MutateModule, FormatModule, DurationModule, TermsModule]; diff --git a/packages/tempo/test/constructor.core.test.ts b/packages/tempo/test/constructor.core.test.ts index e750b54d..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); diff --git a/packages/tempo/test/duration.lazy.test.ts b/packages/tempo/test/duration.lazy.test.ts index 185c69de..4f3cc842 100644 --- a/packages/tempo/test/duration.lazy.test.ts +++ b/packages/tempo/test/duration.lazy.test.ts @@ -7,7 +7,7 @@ describe('Tempo.duration() (Core)', () => { 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 module not loaded/); + expect(() => t.until('2024-01-02')).toThrow(/Tempo: DurationModule module not loaded/); expect(spy).toHaveBeenCalled(); }); 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/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/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/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 af70beaf..1414c650 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,12 +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') }, From 5afc9b7cfad3f0f74ccb680a123dd02d940e34d5 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 11:51:05 +1000 Subject: [PATCH 4/9] v2.2.3 1st draft --- packages/tempo/plan/standalone_parse.md | 99 +++++++++++++------------ 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/packages/tempo/plan/standalone_parse.md b/packages/tempo/plan/standalone_parse.md index 230be57e..0dadb3f1 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**. +- **Phase 1**: Refactor `ParseEngine` to be stateless (accepting config/state as arguments). +- **Phase 2**: Export a wrapper `parse()` function from `#tempo/parse`. +- **Phase 3**: Update `Tempo.class` to delegate its `#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. From f64d76e3696a4f84c2c9267de360c5df0638c110 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 18:58:02 +1000 Subject: [PATCH 5/9] v2.2.3 2nd draft --- packages/tempo/doc/releases/index.md | 2 +- packages/tempo/doc/releases/v0.x.md | 2 +- packages/tempo/doc/releases/v2.x.md | 18 ++ packages/tempo/doc/tempo.api.md | 1 - packages/tempo/doc/tempo.layout.md | 5 + packages/tempo/doc/tempo.library.md | 2 +- packages/tempo/index.md | 214 +++++++++++++----- packages/tempo/package.json | 17 +- packages/tempo/plan/standalone_parse.md | 8 +- packages/tempo/plan/ticker-reactive.md | 55 +++++ packages/tempo/src/library.index.ts | 1 + .../tempo/src/plugin/extend/extend.ticker.ts | 2 +- .../src/plugin/module/module.duration.ts | 12 +- .../tempo/src/plugin/module/module.mutate.ts | 7 +- .../tempo/src/plugin/module/module.parse.ts | 16 +- packages/tempo/src/plugin/plugin.util.ts | 4 +- packages/tempo/src/tempo.class.ts | 68 +++--- packages/tempo/vitest.config.ts | 2 + 18 files changed, 315 insertions(+), 121 deletions(-) create mode 100644 packages/tempo/plan/ticker-reactive.md diff --git a/packages/tempo/doc/releases/index.md b/packages/tempo/doc/releases/index.md index 63843910..cecc728c 100644 --- a/packages/tempo/doc/releases/index.md +++ b/packages/tempo/doc/releases/index.md @@ -8,7 +8,7 @@ Explore the evolution of Tempo through its version history. --- -### Release Strategy +## Release Strategy Tempo follows [Semantic Versioning](https://semver.org/). - **Major**: Breaking changes and architectural shifts. - **Minor**: New features and significant enhancements. diff --git a/packages/tempo/doc/releases/v0.x.md b/packages/tempo/doc/releases/v0.x.md index bfd166bc..01dfdd40 100644 --- a/packages/tempo/doc/releases/v0.x.md +++ b/packages/tempo/doc/releases/v0.x.md @@ -14,7 +14,7 @@ Corrected the enumerable decorator to ensure proper closure. 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 +### Documentation Enhanced inline documentation and added detailed usage examples for enum utilities. ### Chores diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 972d4c07..4d6eb99f 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,5 +1,23 @@ # ๐Ÿ“œ 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 - Globalized module and serialization registries using `Symbol.for()` on `globalThis` to ensure state consistency across module reloads, HMR, and bundled distributions. diff --git a/packages/tempo/doc/tempo.api.md b/packages/tempo/doc/tempo.api.md index cd8d8d08..5d996cbe 100644 --- a/packages/tempo/doc/tempo.api.md +++ b/packages/tempo/doc/tempo.api.md @@ -24,7 +24,6 @@ You can instantiate `Tempo` in several ways: - **`BigInt`**: Unix timestamps in nanoseconds. - **`Date`**: Standard JavaScript `Date` object. - **`Tempo`**: Clones another Tempo instance. -- **`Function`**: A dynamic resolver (max depth 5). - **`Temporal.*`**: Any native Temporal object (ZonedDateTime, PlainDate, etc.). --- diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/tempo.layout.md index ba40a32b..7a865dfc 100644 --- a/packages/tempo/doc/tempo.layout.md +++ b/packages/tempo/doc/tempo.layout.md @@ -30,6 +30,11 @@ Snippets represent specific date or time units. When arranged in a layout, they | `{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 diff --git a/packages/tempo/doc/tempo.library.md b/packages/tempo/doc/tempo.library.md index 615d3360..5daef2e8 100644 --- a/packages/tempo/doc/tempo.library.md +++ b/packages/tempo/doc/tempo.library.md @@ -56,4 +56,4 @@ Tempo provides a specialized wrapper around `Promise.withResolvers()` called `Pl ๐Ÿ‘‰ **[Read the full Pledge Guide](./tempo.pledge.md)** for advanced usage with callbacks, debugging tags, and lifecycle management. ---- + diff --git a/packages/tempo/index.md b/packages/tempo/index.md index cfc42702..3ec0b488 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -8,14 +8,36 @@ 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 function updateHands(h24, m, s) { const h = h24 % 12 @@ -24,37 +46,105 @@ 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 +const 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() + originalHas = registry.has + registry.has = () => false + } + + const [{ Tempo }, { TickerModule }] = await Promise.all([ + import('@magmacomputing/tempo'), + import('@magmacomputing/tempo/ticker'), + ]) + + if (import.meta.env.DEV) registry.has = originalHas + + Tempo.init() + if (!Tempo.ticker) Tempo.extend(TickerModule) + + return Tempo + } catch (e) { + console.error('Tempo failed to initialize:', e) + throw e + } +})() + +async function startTicker() { + try { + const Tempo = await initPromise + if (!isMounted) return + + ticker?.stop() + + 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() + 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() + 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 + clearInterval(carouselTimer) + document.removeEventListener('visibilitychange', handleVisibility) }) @@ -102,18 +192,23 @@ 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.

+ @@ -273,38 +368,47 @@ 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; } +.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 f6d995c9..d36d54e9 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -24,11 +24,10 @@ "type": "module", "sideEffects": [ "**/temporal.polyfill.js", - "**/module.parse.js", - "**/module.mutate.js", + "**/*-polyfill.ts", + "**/module.*.js", + "**/module.*.ts", "**/tempo.index.js", - "**/*.polyfill.ts", - "**/*.module.ts", "src/tempo.index.ts" ], "main": "dist/tempo.index.js", @@ -146,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" @@ -158,7 +162,10 @@ "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.js" + } }, "scripts": { "test": "vitest run", diff --git a/packages/tempo/plan/standalone_parse.md b/packages/tempo/plan/standalone_parse.md index 0dadb3f1..6b4a0179 100644 --- a/packages/tempo/plan/standalone_parse.md +++ b/packages/tempo/plan/standalone_parse.md @@ -42,7 +42,7 @@ To make `ParseEngine.parse` standalone, we would need to: graph TD A[Smart Lexer] --> B[Stateless Parse Engine] B --> C[Standalone parse Function] - B --> D[Tempo Class #parse] + B --> D[Tempo Class parse] C --> E[Temporal.ZonedDateTime] D --> F[Tempo Instance] G[Guard] --> B @@ -51,8 +51,8 @@ graph TD ## 5. Conclusion Implementing a standalone `parse` function is **Highly Feasible**. -- **Phase 1**: Refactor `ParseEngine` to be stateless (accepting config/state as arguments). -- **Phase 2**: Export a wrapper `parse()` function from `#tempo/parse`. -- **Phase 3**: Update `Tempo.class` to delegate its `#parse` logic to this shared stateless engine. +- 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. 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/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..543c431a 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' diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index 73433489..36387a24 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -153,12 +153,20 @@ 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] ?? {}; + const modules = (globalThis as any)[sym.$modules] ??= {}; if (isUndefined(modules['DurationModule'])) { modules['DurationModule'] = duration; } // 2. Inject the static helper - (TempoClass as any).duration = (input: any) => interpret(TempoClass, 'DurationModule', 'toDuration', false, input); + (TempoClass as any).duration = function (this: typeof Tempo, input: any) { + try { + return interpret(this, 'DurationModule', 'toDuration', false, input); + } catch (e) { + // if the static call fails, fallback to an instance-context call to provide + // the helpful "module not loaded" diagnostic consistent with instance UX. + return (new this() as any).duration(input); + } + }; } }); diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/plugin/module/module.mutate.ts index 224d05ea..e7f0f190 100644 --- a/packages/tempo/src/plugin/module/module.mutate.ts +++ b/packages/tempo/src/plugin/module/module.mutate.ts @@ -93,7 +93,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options const slug = `${op}.${single}`; - const parseInner = (input: any, anchor?: any, module?: any) => { + 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); @@ -118,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 = parseInner(offset, currZdt, term); + const res = parseInner(offset, currZdt); if (isUndefined(res)) state.errored = true; return res ?? currZdt; } @@ -204,6 +204,3 @@ const MutateEngine = { * MutateModule registration */ export const MutateModule = defineInterpreterModule('MutateModule', MutateEngine); - -// Eagerly register the engine with the global registry to ensure availability even if .extend() is delayed -_MODULES['MutateModule'] = MutateEngine; diff --git a/packages/tempo/src/plugin/module/module.parse.ts b/packages/tempo/src/plugin/module/module.parse.ts index 96b632c3..8abab8b9 100644 --- a/packages/tempo/src/plugin/module/module.parse.ts +++ b/packages/tempo/src/plugin/module/module.parse.ts @@ -55,11 +55,15 @@ const ParseEngine = { 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)); - const cycleOffset = isMultiCycle ? Math.floor(currentIdx / itemsPerCycle) * itemsPerCycle : 0; + 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]; @@ -78,10 +82,6 @@ const ParseEngine = { } } - const isAnchored = isDefined(dateTime) || isDefined(state.anchor); - const resolvingKeys = new Set(); - const res = ParseEngine.conform.call(this, tempo, today, isAnchored, resolvingKeys); - if (isString(tempo) && tempo.startsWith('#')) { const res = resolveTermValue(TempoClass, this, tempo, today); if (isZonedDateTime(res)) return res; @@ -99,6 +99,10 @@ const ParseEngine = { 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; @@ -281,7 +285,7 @@ const ParseEngine = { }, /** apply a regex-match against a value, and clean the result */ - parseMatch(this: any, pat: RegExp, value: string | number | (() => string)) { + parseMatch(this: any, pat: RegExp, value: string | number) { const groups = value.toString().match(pat)?.groups || {} ownEntries(groups) diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index a5790f5e..f5024193 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -43,7 +43,9 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent // 1. Module Validation if (!ensureModule(t, module, silent)) { - return isFunction(methodOrFallback) ? methodOrFallback.apply(t, args) : undefined; + if (isFunction(methodOrFallback)) return methodOrFallback.apply(t, args); + if (isString(methodOrFallback) && (t?.config?.catch === true || silent)) return t; + return undefined; } const hostLogic = (REGISTRY.modules as any)[module]; diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 5c5d4c53..7df93e83 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -40,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; @@ -235,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; } @@ -245,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 } @@ -967,31 +961,20 @@ export class Tempo { /** constructor tempo */ #tempo?: t.DateTime; /** constructor options */ #options = {} as t.Options; /** 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; - /** 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; - /** current parsing depth to manage state isolation */ #parseDepth_ = 0; - /** current mutation depth to manage infinite recursion */#mutateDepth_ = 0; + /** underlying Temporal ZonedDateTime */ #zdt!: Temporal.ZonedDateTime; + /** indicator that the instance failed to parse */ #errored = false; + /** 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; + /** current parsing depth to manage state isolation */ #parseDepth = 0; + /** current mutation depth to manage infinite recursion */#mutateDepth = 0; /** instance values to complement static values */ #local = { /** instance configuration */ config: { [lib.$Logify]: true } as unknown as Internal.Config, /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.Match[] } as Internal.Parse } as Internal.State; - get #zdt(): Temporal.ZonedDateTime { return this.#zdt_ as Temporal.ZonedDateTime } - set #zdt(val: Temporal.ZonedDateTime) { this.#zdt_ = val } - get #errored(): boolean { return this.#errored_ } - set #errored(val: boolean) { this.#errored_ = val } - get #parseDepth(): number { return this.#parseDepth_ ?? 0 } - set #parseDepth(val: number) { this.#parseDepth_ = val } - get #mutateDepth(): number { return this.#mutateDepth_ ?? 0 } - set #mutateDepth(val: number) { this.#mutateDepth_ = val } - get #matches(): Internal.Match[] | undefined { return this.#matches_ } - set #matches(val: Internal.Match[] | undefined) { this.#matches_ = val } - get #anchor(): Temporal.ZonedDateTime | undefined { return this.#anchor_ } - set #anchor(val: Temporal.ZonedDateTime | undefined) { this.#anchor_ = val } + /** @internal internal key for signaling pre-errored state in constructor */ static [sym.$errored] = sym.$errored; @@ -1050,7 +1033,7 @@ export class Tempo { /** iterate over instance formats */ [Symbol.iterator]() { - return ownEntries(this.#fmt_, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] + return ownEntries(this.#fmt, true)[Symbol.iterator](); // instance Iterator over tuple of FormatType[] } get [Symbol.toStringTag]() { // default string description @@ -1087,8 +1070,8 @@ export class Tempo { this.#local.parse.lazy = true; // auto-switch to lazy-mode for valid strings } - this.#fmt_ = this.#setDelegator('fmt'); // initialize the format-delegator - this.#term_ = this.#setDelegator('term'); // initialize the term-delegator + this.#fmt = this.#setDelegator('fmt'); // initialize the format-delegator + this.#term = this.#setDelegator('term'); // initialize the term-delegator this.#anchor = this.#options.anchor; if ((this.#options as any)[sym.$errored]) this.#errored = true; @@ -1317,8 +1300,8 @@ export class Tempo { return this.#local.parse; } - /** Keyed results for all resolved terms */ 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), @@ -1352,8 +1335,8 @@ export class Tempo { return interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1); } - /** 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); } - /** 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); } + /** 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 */ @@ -1397,7 +1380,16 @@ export class Tempo { /** parse DateTime input */ #parse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { - return interpret(this, 'ParseModule', 'parse', true, tempo, dateTime, term) ?? this.#fallbackParse(tempo, dateTime, term); + 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; } #fallbackParse(tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 1414c650..b0a18e60 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -46,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') }, From 8688e224eab66783097f6699f97c9b12d79c8818 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 19 Apr 2026 19:49:07 +1000 Subject: [PATCH 6/9] v2.2.3 3rd draft --- packages/tempo/README.md | 42 +++++++-- packages/tempo/doc/releases/v2.x.md | 16 ++-- packages/tempo/importmap.json | 2 + packages/tempo/index.md | 86 +++++++++++++++++-- packages/tempo/package.json | 3 +- .../src/plugin/module/module.duration.ts | 8 +- .../tempo/src/plugin/module/module.mutate.ts | 2 +- .../tempo/src/plugin/module/module.parse.ts | 3 - packages/tempo/src/plugin/plugin.util.ts | 2 +- packages/tempo/src/tempo.class.ts | 12 +-- 10 files changed, 132 insertions(+), 44 deletions(-) diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 7fbd7e6f..b7d511a5 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -5,7 +5,7 @@ Tempo logo -

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/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 4d6eb99f..27a4a5a7 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -3,20 +3,20 @@ ## [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 +- 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 +- 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 +- 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 +- Improved module-loading diagnostics and general doc formatting cleanup ## [v2.2.2] - 2026-04-18 ### ๐Ÿ›ก๏ธ Registry Stabilization 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 3ec0b488..85e364ab 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -38,6 +38,8 @@ 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 @@ -47,14 +49,15 @@ function updateHands(h24, m, s) { } // One-time library setup -const initPromise = (async () => { +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() - originalHas = registry.has + // HMR Workaround: Temporarily bypass registry presence checks to allow class re-hydration + originalHas = registry.has.bind(registry) registry.has = () => false } @@ -71,16 +74,21 @@ const initPromise = (async () => { 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() @@ -100,7 +108,8 @@ async function startTicker() { tzStr.value = Intl.DateTimeFormat().resolvedOptions().timeZone } fallback() - setInterval(fallback, 1000) + if (fallbackIntervalId) clearInterval(fallbackIntervalId) + fallbackIntervalId = setInterval(fallback, 1000) } } @@ -128,6 +137,7 @@ function handleVisibility() { startCarousel() } else { ticker?.stop() + if (fallbackIntervalId) clearInterval(fallbackIntervalId) clearInterval(carouselTimer) carouselTimer = null } @@ -143,9 +153,32 @@ onMounted(() => { onUnmounted(() => { isMounted = false ticker?.stop() + 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) +}
@@ -193,16 +226,32 @@ onUnmounted(() => {